diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/registry.rs | 16 | ||||
| -rw-r--r-- | src/access/rules.rs | 4 | ||||
| -rw-r--r-- | src/access/token.rs | 21 | ||||
| -rw-r--r-- | src/api/login.rs | 62 | ||||
| -rw-r--r-- | src/document/meta.rs | 3 | ||||
| -rw-r--r-- | src/node.rs | 1 | ||||
| -rw-r--r-- | src/remake/snapshot.rs | 72 | ||||
| -rw-r--r-- | src/state/mod.rs | 5 |
8 files changed, 176 insertions, 8 deletions
diff --git a/src/access/registry.rs b/src/access/registry.rs index 01e6ba9..e806d0c 100644 --- a/src/access/registry.rs +++ b/src/access/registry.rs @@ -146,6 +146,22 @@ pub(crate) fn logouts_path(name: &str) -> Vec<String> { meta::path(&[meta::LOGOUTS, name]) } +/// Where user `name`'s session `session` is recorded as ended (logged out of): its +/// tokens aren't taken any more (`token::verify`). The value: until when the session +/// would have lasted (Unix ms), after which it is left out of snapshots. Written by the +/// user itself (`DELETE /auth/session`) or by one who may manage users. +pub(crate) fn session_path(name: &str, session: &str) -> Vec<String> { + meta::path(&[meta::SESSIONS, name, session]) +} + +/// Whether user `name`'s session `session` was ended (`session_path`). +pub(crate) fn session_ended(doc: &impl ReadDoc, name: &str, session: &str) -> bool { + store::read_path(doc, &session_path(name, session)) + .ok() + .flatten() + .is_some() +} + /// The users who may manage users: enabled, with `write` on `_continuum/users`. pub(crate) fn managers(doc: &impl ReadDoc) -> Vec<String> { names(doc) diff --git a/src/access/rules.rs b/src/access/rules.rs index d120961..5c839af 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -269,7 +269,9 @@ pub(crate) fn judge_reading( (Some(meta::LOCAL), _) => false, // Logging out: a user revokes its own tokens (`POST /auth/logout`); one who // manages users, anyone's (`DELETE /admin/users/{name}/tokens`). - (Some(meta::LOGOUTS), Some(owner)) => { + // Ending a session: a user its own (`DELETE /auth/session`); one who manages + // users, anyone's. + (Some(meta::LOGOUTS | meta::SESSIONS), Some(owner)) => { change.user == Some(owner) || user_may(path) || user_may(&meta::path(&[meta::USERS])) diff --git a/src/access/token.rs b/src/access/token.rs index d33a3fe..e7aff42 100644 --- a/src/access/token.rs +++ b/src/access/token.rs @@ -89,13 +89,16 @@ pub(crate) enum Refused { Disabled, #[error("revoked: issued before its user's tokens were")] Revoked, + #[error("its session was ended (logged out of)")] + Ended, } /// The claims of `token` if this node takes it: well formed, for `swarm`, signed by a /// node that is a member in `doc` and not banned, not expired at `now` (Unix ms), nor issued later /// than a clock off by `SKEW_MS` would; and in a swarm with a registry, for a /// registered user that is enabled, issued after its tokens were last revoked -/// (`registry::User::tokens_not_before`). +/// (`registry::User::tokens_not_before`), of a session not ended +/// (`registry::session_ended`). pub(crate) fn verify( doc: &impl ReadDoc, token: &str, @@ -150,6 +153,9 @@ pub(crate) fn verify( if claims.iat < user.tokens_not_before { return Err(Refused::Revoked); } + if registry::session_ended(doc, &claims.user, &claims.session) { + return Err(Refused::Ended); + } } Ok(claims) } @@ -290,6 +296,19 @@ mod tests { store::write_path(tx, ®istry::logouts_path("alice"), &json!(1_001)).unwrap() }); assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Revoked)); + // One session ended: its tokens refused, another's taken. + let session = claims_of(&token).session; + change(&mut doc, |tx| { + store::write_path(tx, ®istry::logouts_path("alice"), &json!(0)).unwrap(); + store::write_path( + tx, + ®istry::session_path("alice", &session), + &json!(10_000), + ) + .unwrap() + }); + assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Ended)); + assert!(verify(&doc, &issue(&key, &claims(&key)), "s", 1_500).is_ok()); alice["disabled"] = json!(true); change(&mut doc, |tx| { store::write_path(tx, ®istry::logouts_path("alice"), &json!(0)).unwrap(); diff --git a/src/api/login.rs b/src/api/login.rs index cc70c18..ec0c9cf 100644 --- a/src/api/login.rs +++ b/src/api/login.rs @@ -113,7 +113,19 @@ pub(crate) async fn bearer( let verified = { let replica = state.doc.read().await; token::verify(&replica.doc, &text, &swarm, now) - }; + } + .and_then(|claims| { + // Ended here where the document can't record it (no registry). + let ended = state + .ended_sessions + .lock_or_recover() + .contains_key(&claims.session); + if ended { + Err(token::Refused::Ended) + } else { + Ok(claims) + } + }); match verified { Ok(claims) => { request @@ -419,17 +431,55 @@ fn over_tls(headers: &axum::http::HeaderMap) -> bool { }) } -/// `DELETE /auth/session`: the session cookie forgotten (the token itself stays valid -/// until it expires; `POST /auth/logout` ends every one). A stale one is ignored anyway. -pub(crate) async fn end_session() -> Response { - ( +/// `DELETE /auth/session`: the session ended, with the token it comes with (the cookie, +/// or `Authorization: Bearer`): recorded in the document (`registry::session_path`), so +/// no node takes its tokens any more, where the swarm has a registry; else on this node +/// only (`AppState::ended_sessions`), others taking them until they expire. And the +/// session cookie forgotten. Without a token, only that. +pub(crate) async fn end_session( + State(state): State<AppState>, + token: Option<Extension<Token>>, +) -> Result<Response, ApiError> { + if let Some(Extension(token)) = token { + end(&state, &token.claims, &token.claims.user).await?; + } + Ok(( StatusCode::NO_CONTENT, [( axum::http::header::SET_COOKIE, format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), )], ) - .into_response() + .into_response()) +} + +/// Ends the session of `claims`, as `by`: recorded in the document where the swarm has a +/// registry, else on this node only. +async fn end(state: &AppState, claims: &Claims, by: &str) -> Result<(), ApiError> { + let path = registry::session_path(&claims.user, &claims.session); + let until = claims.session_exp; + let recorded = if state.is_ready().await { + state + .mutate_doc_for(Some(by.to_owned()), Sign::Protocol, move |doc| { + if !registry::exists(&*doc) { + return Ok::<_, ApiError>(false); + } + crate::document::store::write_path(doc, &path, &json!(until))?; + Ok(true) + }) + .await? + .0 + } else { + false + }; + if !recorded { + let now = state.clock.now_millis().as_u64(); + let mut ended = state.ended_sessions.lock_or_recover(); + ended.retain(|_, until| *until > now); + ended.insert(claims.session.clone(), until); + } + info!(user = %claims.user, session = %claims.session, by = %by, recorded, "Ended a session"); + Ok(()) } /// The claims a refresh of `token` gives (`limit` within its own). diff --git a/src/document/meta.rs b/src/document/meta.rs index e040ba0..3b693cf 100644 --- a/src/document/meta.rs +++ b/src/document/meta.rs @@ -21,6 +21,8 @@ //! * `access/userSigned`: the paths whose values their users sign (`access::envelope`). //! * `banned/<pk>`: banned nodes (`access::bans`). //! * `logouts/<name>`: when a user's tokens were last revoked (`access::registry`). +//! * `sessions/<name>/<session>`: a session of the user ended (logged out of), until +//! when it would have lasted: its tokens aren't taken (`access::registry`). //! * `local/<pk>/<name>`: never stored, and never writable: what a node holds alone //! (its log filter, its configuration, its debug endpoints), named for capabilities //! (`api::access::needed`). @@ -41,6 +43,7 @@ pub(crate) const USERS: &str = "users"; pub(crate) const ACCESS: &str = "access"; pub(crate) const BANNED: &str = "banned"; pub(crate) const LOGOUTS: &str = "logouts"; +pub(crate) const SESSIONS: &str = "sessions"; pub(crate) const LOCAL: &str = "local"; /// The store path `_continuum/<parts…>`. diff --git a/src/node.rs b/src/node.rs index 2f82c96..309c126 100644 --- a/src/node.rs +++ b/src/node.rs @@ -192,6 +192,7 @@ impl Node { roots: Default::default(), genesis_root: config.genesis_root, challenges: Default::default(), + ended_sessions: Default::default(), token_lifetime: config.token_lifetime, session_lifetime: config.session_lifetime, challenge_lifetime: config.challenge_lifetime, diff --git a/src/remake/snapshot.rs b/src/remake/snapshot.rs index e82a2ab..2bb5fbb 100644 --- a/src/remake/snapshot.rs +++ b/src/remake/snapshot.rs @@ -289,6 +289,31 @@ pub(crate) fn epoch_metadata_path(generation: u64) -> Vec<String> { meta::path(&[EPOCHS, generation.to_string().as_str()]) } +/// The ended sessions `doc` records (`_continuum/sessions`), by user, each session with +/// until when it would have lasted (one that doesn't read: at once, so it goes). +fn ended_sessions( + doc: &impl ReadDoc, +) -> Result<BTreeMap<String, BTreeMap<String, u64>>, StoreError> { + let mut users = BTreeMap::new(); + for user in store::keys_at(doc, &meta::path(&[meta::SESSIONS]))?.unwrap_or_default() { + let path = meta::path(&[meta::SESSIONS, &user]); + let sessions = store::keys_at(doc, &path)? + .unwrap_or_default() + .into_iter() + .map(|session| { + let until = store::read_plain(doc, &meta::path(&[meta::SESSIONS, &user, &session])) + .ok() + .flatten() + .and_then(|until| until.as_u64()) + .unwrap_or(0); + (session, until) + }) + .collect(); + users.insert(user, sessions); + } + Ok(users) +} + /// Of `earlier` epochs' metadata (by generation), the generations a snapshot of /// `generation`, proposed at `proposed_at`, leaves out: those no rejoining node replays /// through any more. A node on epoch P replays while the replay window of epoch P+1 @@ -377,6 +402,23 @@ pub(crate) fn build( for g in left_out(&earlier, generation, params.proposed_at) { store::delete_path(&mut target, &epoch_metadata_path(g), false)?; } + // Ended sessions past when they would have lasted: their tokens are refused as + // expired anyway. A user's whole map where none is left. + for (user, ended) in ended_sessions(&base)? { + let gone: Vec<&String> = ended + .iter() + .filter(|(_, until)| **until <= params.proposed_at.as_u64()) + .map(|(session, _)| session) + .collect(); + if gone.len() == ended.len() { + store::delete_path(&mut target, &meta::path(&[meta::SESSIONS, &user]), false)?; + } else { + for session in gone { + let path = meta::path(&[meta::SESSIONS, &user, session]); + store::delete_path(&mut target, &path, false)?; + } + } + } // What the kind does to it (a strict remake keeps only what its manifest names). kind.protocol(&base, params.content, &mut target)?; @@ -691,6 +733,36 @@ mod tests { assert_eq!(epoch["baseHeads"].as_array().unwrap().len(), heads.len()); } + /// Ended sessions go once they would have ended anyway; a user's map with them. + #[test] + fn ended_sessions_left_out_once_past() { + let mut a = doc(b"actor-a"); + change(&mut a, |d| { + for (user, session, until) in + [("alice", "s1", 5), ("alice", "s2", 50), ("bob", "s3", 5)] + { + store::write_path( + d, + &meta::path(&[meta::SESSIONS, user, session]), + &json!(until), + ) + .unwrap(); + } + }); + let policy = json!({}); + let heads = a.get_heads(); + let mut at = params(&heads, &policy); + at.proposed_at = UnixMillis(10); + let built = build(&a, &at).unwrap(); + let keys = |path: &[&str]| { + store::keys_at(&built.doc, &meta::path(path)) + .unwrap() + .unwrap_or_default() + }; + assert_eq!(keys(&[meta::SESSIONS]), vec!["alice"]); + assert_eq!(keys(&[meta::SESSIONS, "alice"]), vec!["s2"]); + } + /// Earlier epochs' metadata goes once no rejoining node replays through it, oldest /// first, but for a strict remake's; what doesn't read stays. #[test] diff --git a/src/state/mod.rs b/src/state/mod.rs index 26910b6..a2da643 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -156,6 +156,11 @@ pub(crate) struct AppState { pub(crate) screening: Arc<std::sync::Mutex<crate::access::screen::Screening>>, /// The challenges logins by key used, until they expire (`access::challenge`). pub(crate) challenges: Arc<std::sync::Mutex<crate::access::challenge::Used>>, + /// Sessions ended on this node in a swarm without a registry, where they can't be + /// recorded in the document: by session ID, until when each would have lasted + /// (`api::login::end_session`). This node refuses their tokens; others take them + /// until they expire, and so does this one after a restart. + pub(crate) ended_sessions: Arc<std::sync::Mutex<std::collections::BTreeMap<String, u64>>>, pub(crate) token_lifetime: Duration, pub(crate) session_lifetime: Duration, /// How long a challenge to log in by key is valid, and how many logins by key a user |
