Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make auth system more flexible & convenient and add "callbacks" #1032

Merged
merged 21 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
58c5368
Add experimental `{auth/login}-callback` auth modes
LukasKalbertodt Dec 14, 2023
d946b9c
Move configuration code from `auth/mod.rs` to `auth/config.rs`
LukasKalbertodt Jan 16, 2024
3da6f67
Add `auth.callback_headers`
LukasKalbertodt Jan 24, 2024
c36f84d
Add caching for auth callbacks
LukasKalbertodt Jan 29, 2024
93e155d
Regularly deleted outdated entries from auth caches
LukasKalbertodt Jan 29, 2024
0641f66
Rethink auth configuration system (BREAKING)
LukasKalbertodt Jan 30, 2024
94425ce
Remove config options for changing the auth header names (BREAKING)
LukasKalbertodt Jan 30, 2024
6101abe
Move role related configs into own section `auth.roles` (BREAKING)
LukasKalbertodt Jan 30, 2024
ba8e021
Fix docs of `logout_link`
LukasKalbertodt Jan 30, 2024
33a5e8f
Rewrite all user-auth related docs
LukasKalbertodt Jan 31, 2024
981aa22
Add `relevant_cookies` config to improve upon `relevant_headers`
LukasKalbertodt Jan 31, 2024
bb780ae
Make `userRole` a separate field in auth callbacks
LukasKalbertodt Jan 31, 2024
c5a7e53
Add utility `GET /~session` route
LukasKalbertodt Jan 31, 2024
9f2d009
Ensure that callback URLs are using HTTPs for remote hosts
LukasKalbertodt Feb 1, 2024
1f60d01
Add basic metrics about auth callback and cache
LukasKalbertodt Feb 1, 2024
4e10251
Adjust config for test deployment
LukasKalbertodt Feb 1, 2024
b5a3b64
Address most review comments
LukasKalbertodt Feb 12, 2024
1c74231
Adjust Shibboleth example with Tobira session
LukasKalbertodt Feb 12, 2024
e1135c2
Allow `"0"` as duration value in the config
LukasKalbertodt Feb 12, 2024
3fe339f
Fix navigation to `logout_link` being cancelled
LukasKalbertodt Feb 13, 2024
5f6b4db
Improve error log by printing full error detail
LukasKalbertodt Feb 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .deployment/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ unix_socket = "/opt/tobira/{{ id }}/socket/tobira.sock"
unix_socket_permissions = 0o777

[auth]
mode = "login-proxy"
source = "tobira-session"
session.from_session_endpoint = "trust-auth-headers"
login_page.note.en = 'Dummy users: "jose", "morgan", "björk", "sabine" and "admin". Password for all: "tobira".'
login_page.note.de = 'Testnutzer: "jose", "morgan", "björk", "sabine" und "admin". Passwort für alle: "tobira".'

Expand Down
1 change: 1 addition & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ time = "0.3"
tokio = { version = "=1.28", features = ["fs", "rt-multi-thread", "macros", "time"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-serde_json-1"] }
tokio-postgres-rustls = "0.10.0"
url = "2.4.1"

[target.'cfg(target_os = "linux")'.dependencies]
procfs = "0.15.1"
Expand Down
166 changes: 160 additions & 6 deletions backend/src/auth/cache.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,83 @@
use std::{time::{Instant, Duration}, collections::HashSet};
use std::{collections::HashSet, hash::Hash, time::{Instant, Duration}};

use dashmap::{DashMap, mapref::entry::Entry};
use deadpool_postgres::Client;
use hyper::HeaderMap;
use prometheus_client::metrics::counter::Counter;

use crate::prelude::*;
use crate::{config::Config, prelude::*};

use super::{config::CallbackConfig, User};

pub struct Caches {
pub(crate) user: UserCache,
pub(crate) callback: AuthCallbackCache,
}

impl Caches {
pub fn new() -> Self {
Self {
user: UserCache::new(),
callback: AuthCallbackCache::new(),
}
}

/// Starts a daemon that regularly removes outdated entries from the cache.
pub(crate) async fn maintainence_task(&self, config: &Config) -> ! {
fn cleanup<K: Eq + Hash, V>(
now: Instant,
map: &DashMap<K, V>,
cache_duration: Duration,
mut timestamp: impl FnMut(&V) -> Instant,
) -> Option<Instant> {
let mut out = None;
map.retain(|_, v| {
let instant = timestamp(v);
let is_outdated = now.saturating_duration_since(instant) > cache_duration;
if !is_outdated {
out = match out {
None => Some(instant),
Some(existing) => Some(std::cmp::min(existing, instant)),
};
}
!is_outdated
});
out.map(|out| out + cache_duration)
}

let empty_wait_time = std::cmp::min(CACHE_DURATION, config.auth.callback.cache_duration);
tokio::time::sleep(empty_wait_time).await;

loop {
let now = Instant::now();
let next_user_action =
cleanup(now, &self.user.0, CACHE_DURATION, |v| v.last_written_to_db);
let next_callback_action =
cleanup(now, &self.callback.map, config.auth.callback.cache_duration, |v| v.timestamp);

// We will wait until the next entry in the hashmap gets stale, but
// at least 30s to not do cleanup too often. In case there are no
// entries currently, it will also retry in 30s. But we will wait
// at most as long as we would do for an empty cache.
let next_action = [next_user_action, next_callback_action].into_iter()
.filter_map(|x| x)
.min();
let wait_duration = std::cmp::min(
std::cmp::max(
next_action.map(|i| i.saturating_duration_since(now))
.unwrap_or(empty_wait_time),
Duration::from_secs(30),
),
empty_wait_time,
);
tokio::time::sleep(wait_duration).await;
}
}
}

const CACHE_DURATION: Duration = Duration::from_secs(60 * 10);

struct CacheEntry {
struct UserCacheEntry {
display_name: String,
email: Option<String>,
roles: HashSet<String>,
Expand All @@ -23,10 +92,10 @@ struct CacheEntry {
/// This works fine in multi-node setups: each node just has its local cache and
/// prevents some DB writes. But as this data is never used otherwise, we don't
/// run into data inconsistency problems.
pub(crate) struct UserCache(DashMap<String, CacheEntry>);
pub(crate) struct UserCache(DashMap<String, UserCacheEntry>);

impl UserCache {
pub(crate) fn new() -> Self {
fn new() -> Self {
Self(DashMap::new())
}

Expand All @@ -50,7 +119,7 @@ impl UserCache {
Entry::Vacant(vacant) => {
let res = Self::write_to_db(user, db).await;
if res.is_ok() {
vacant.insert(CacheEntry {
vacant.insert(UserCacheEntry {
display_name: user.display_name.clone(),
email: user.email.clone(),
roles: user.roles.clone(),
Expand Down Expand Up @@ -95,3 +164,88 @@ impl UserCache {
}
}


// ---------------------------------------------------------------------------

#[derive(PartialEq, Eq)]
struct AuthCallbackCacheKey(HeaderMap);

impl Hash for AuthCallbackCacheKey {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
// Sigh, unfortunately this requires us to sort the headers in order to
// get the same hash for the same set of headers. Well, there might be
// some clever ways to avoid that, but the `Hasher` trait is quite
// limited and does not allow these clever tricks, at least not without
// basically writing your own hashing logic.
let mut keys = self.0.keys().collect::<Vec<_>>();
keys.sort_by_key(|hn| hn.as_str());

for key in keys {
for value in self.0.get_all(key) {
key.hash(state);
b": ".hash(state);
value.hash(state);
state.write_u8(b'\n');
}
}
}
}

struct AuthCallbackCacheEntry {
user: Option<User>,
timestamp: Instant,
}


/// Cache for `auth-callback` calls.
pub(crate) struct AuthCallbackCache {
map: DashMap<AuthCallbackCacheKey, AuthCallbackCacheEntry>,
// Metrics
hits: Counter,
misses: Counter,
}

impl AuthCallbackCache {
fn new() -> Self {
Self {
map: DashMap::new(),
hits: Counter::default(),
misses: Counter::default(),
}
}

pub(crate) fn hits(&self) -> &Counter {
&self.hits
}

pub(crate) fn misses(&self) -> &Counter {
&self.misses
}
pub(crate) fn size(&self) -> usize {
self.map.len()
}

pub(super) fn get(&self, key: &HeaderMap, config: &CallbackConfig) -> Option<Option<User>> {
// TODO: this `clone` should not be necessary. It can be removed with
// `#[repr(transparent)]` and an `unsafe`, but I don't want to just
// throw around `unsafe` here.
let out = self.map.get(&AuthCallbackCacheKey(key.clone()))
.filter(|e| e.timestamp.elapsed() < config.cache_duration)
.map(|e| e.user.clone());

match out.is_some() {
true => self.hits.inc(),
false => self.misses.inc(),
};

out
}

pub(super) fn insert(&self, key: HeaderMap, user: Option<User>) {
self.map.insert(AuthCallbackCacheKey(key), AuthCallbackCacheEntry {
user,
timestamp: Instant::now(),
});
}
}

Loading
Loading