aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 12:25:09 +0000
committertv <tv@krebsco.de>2026-11-09 13:32:05 +0100
commitd12ccc782d8e6eab46eb6e0976b747dca1013b36 (patch)
treede9d5ae9135a182dbb19a6067d240054483561d4 /src
parentb0e711f9f7e0d2d9f6b39dd987004d2f9b1dcda7 (diff)
access: ending a single session
Logging out of the dashboard (`DELETE /auth/session`) only cleared the cookie: the session's tokens stayed taken until they expired, by every node. Ending them took `POST /auth/logout`, which ends all of a user's sessions. Now `DELETE /auth/session` ends the session of the token it comes with (the cookie, or a bearer token): where the swarm has a registry, it is recorded as `_continuum/sessions/<user>/<session>` (`registry::session_path`), its value until when the session would have lasted, and every node refuses the session's tokens once the change reaches it (`token::Refused::Ended`): the page's own, its narrow tokens, and one handed to another member's dashboard, all refreshed from it and keeping its ID. The rules take the entry from the user itself or one who manages users, as a logout. A remake's snapshot leaves out entries past that time, a user's map where none is left: their tokens are expired anyway. Without a registry the document can't record it: the node ending it refuses the session's tokens itself (`AppState::ended_sessions`, until it restarts), others take them until they expire, as a logout there is refused.
Diffstat (limited to 'src')
-rw-r--r--src/access/registry.rs16
-rw-r--r--src/access/rules.rs4
-rw-r--r--src/access/token.rs21
-rw-r--r--src/api/login.rs62
-rw-r--r--src/document/meta.rs3
-rw-r--r--src/node.rs1
-rw-r--r--src/remake/snapshot.rs72
-rw-r--r--src/state/mod.rs5
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, &registry::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, &registry::logouts_path("alice"), &json!(0)).unwrap();
+ store::write_path(
+ tx,
+ &registry::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, &registry::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