aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md6
-rw-r--r--docs/openapi.yaml9
-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
10 files changed, 188 insertions, 11 deletions
diff --git a/docs/access.md b/docs/access.md
index 50c0265..988d619 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -124,7 +124,11 @@ curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store
* **Revoking**: a user disabled or removed is refused at once; all of a user's tokens,
by the user (`POST /auth/logout`) or by one who may manage users (`DELETE
/admin/users/{name}/tokens`), which sets `_continuum/logouts/<name>`: tokens issued
- before aren't taken any more. A token of a node banned or forgotten by the swarm isn't
+ before aren't taken any more. One session's (`DELETE /auth/session`, the dashboard's
+ "log out"): `_continuum/sessions/<name>/<session>`, until when the session would have
+ lasted, written by the user or one who manages users; a remake's snapshot leaves it
+ out once that has passed. Without a registry, the node ending it refuses the
+ session's tokens itself, until it restarts; others take them until they expire. A token of a node banned or forgotten by the swarm isn't
taken either. In a swarm without a registry, a token is taken until it expires. The
user a logout names is its node's word, and a logout no deciding path, even under a
strict policy: any member could log any user out. It ends sessions; and a value there
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 03afa2f..adee4a6 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -923,8 +923,13 @@ paths:
'503': { description: Not ready yet. }
delete:
tags: [Auth]
- summary: Forget the browser's session
- description: The session cookie cleared. The token stays valid until it expires (`POST /auth/logout` ends them all).
+ summary: End the session
+ description: |
+ The session of the token it comes with (the cookie, or `Authorization: Bearer`)
+ ended: recorded as `_continuum/sessions/<user>/<session>` where the swarm has a
+ registry, so no node takes its tokens any more once the change reaches it; else on
+ this node only, others taking them until they expire. And the session cookie
+ cleared. (`POST /auth/logout` ends all of a user's sessions.)
responses:
'204': { description: Forgotten. }
'401':
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