From 165561f6a2fc32e00732ac076ec5fe3b3052f515 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 12:19:26 +0200 Subject: [PATCH] sessions: every signed-in browser is a row, and one can be signed out A session cookie now carries the id of a row in the new sessions table, and is refused once that row is gone. :sessions lists the browsers signed in, newest first, and :sessions rm signs one out without touching the rest; it locks the vault too, since the vault is open for the whole workspace. Logging out deletes the row, :logout! and a login password change delete them all, and the sweep drops expired rows. last_seen is kept in memory and written at most every five minutes. Cookies issued before this carry no id and are refused: every browser logs in once more after the upgrade. --- README.md | 8 +- migrations/0017_sessions.sql | 11 ++ src/auth.rs | 295 +++++++++++++++++++++++++++++------ src/main.rs | 2 + src/routes/mod.rs | 3 + src/routes/session.rs | 199 ++++++++++++++++++++++- src/state.rs | 5 +- src/sweep.rs | 5 + web/src/lib/api.ts | 13 ++ web/src/lib/commands.ts | 55 ++++++- 10 files changed, 540 insertions(+), 56 deletions(-) create mode 100644 migrations/0017_sessions.sql diff --git a/README.md b/README.md index d009bdb..9b8cefa 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ keeps running untouched. `WORKSPACE_SECRET` signs the session cookie; generate one with `openssl rand -base64 32`. Changing it logs every device out, and so does changing `WORKSPACE_PASSWORD` or `:logout!`, which ends every session but -needs no restart. A setting that is set but cannot be read — a number out of +needs no restart. Every signed-in browser is a row on the server: `:sessions` +lists them and `:sessions rm ` signs one out — a lost phone, say — without +touching the rest, and locks the vault as well. A setting that is set but cannot be read — a number out of range, a switch that is neither on nor off — stops the start with the reason, rather than being taken for its default. The database and uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is @@ -106,7 +108,7 @@ list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, `:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words and lines, `:search!` searches every space, `:set clipboard` / `:set noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the -session and `:logout!` every session. The tree has its own: `:space [name]` switches (`:space! ` +session, `:logout!` every session and `:sessions rm ` one other. The tree has its own: `:space [name]` switches (`:space! ` makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir ` makes a folder, `:cd ` walks it and `:mv ` moves what is selected, and `:import` uploads a folder into the one being browsed @@ -561,6 +563,8 @@ sealed or not. | `GET` | `/api/health` | public: up, and the database answers — for a supervisor | | `POST` | `/api/login`, `/api/logout` | session; `/api/logout?everywhere=true` ends every one | | `GET` | `/api/me` | current user | +| `GET` | `/api/sessions` | every signed-in browser, the one seen last first | +| `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault | | `GET`/`POST` | `/api/spaces` | the spaces / create one | | `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create | | `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) | diff --git a/migrations/0017_sessions.sql b/migrations/0017_sessions.sql new file mode 100644 index 0000000..0b0fdde --- /dev/null +++ b/migrations/0017_sessions.sql @@ -0,0 +1,11 @@ +-- Every signed-in browser gets a row, named by an id its cookie carries, so +-- one can be listed and signed out without signing out the rest. A cookie +-- whose row is gone is refused even though its signature still holds. +-- The epoch in `auth` stays as a second fence: raising it ends every row too. +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + user_agent TEXT NOT NULL DEFAULT '' +); diff --git a/src/auth.rs b/src/auth.rs index 08004fb..86e9b05 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,4 +1,4 @@ -use std::convert::Infallible; +use std::{collections::HashMap, convert::Infallible, sync::Mutex}; use axum::{ extract::{FromRequestParts, OptionalFromRequestParts, Request}, @@ -27,36 +27,153 @@ fn sign(secret: &[u8], payload: &str) -> String { B64.encode(mac.finalize().into_bytes()) } -/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no -/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session. -/// A cookie is good until it expires and for as long as the epoch it was -/// issued under is the current one. -pub fn issue_token(config: &Config, epoch: i64) -> String { - let expires = crate::now() + config.session_days * 86_400; - let payload = B64.encode(format!("{expires}:{epoch}")); +/// A session token is `base64("{expires}:{epoch}:{id}").base64(hmac)`. +/// The signature keeps anyone from making one up; the id names the +/// [`Sessions`] row it belongs to, and a cookie is good only while that row +/// is there, it has not expired, and the epoch it was issued under is the +/// current one. Rotating `WORKSPACE_SECRET` still invalidates every session. +pub fn issue_token(expires: i64, epoch: i64, id: &str, config: &Config) -> String { + let payload = B64.encode(format!("{expires}:{epoch}:{id}")); let signature = sign(&config.secret, &payload); format!("{payload}.{signature}") } -pub fn verify_token(config: &Config, token: &str, epoch: i64) -> bool { - let Some((payload, signature)) = token.split_once('.') else { - return false; - }; +/// When a session started now runs out. +pub fn session_expiry(config: &Config) -> i64 { + crate::now() + config.session_days * 86_400 +} + +/// The session id a token carries, if it is genuine, unexpired and of this +/// epoch. A cookie from before sessions had ids carries none, and is refused. +pub fn verify_token(config: &Config, token: &str, epoch: i64) -> Option { + let (payload, signature) = token.split_once('.')?; let expected = sign(&config.secret, payload); if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) { - return false; + return None; + } + let decoded = String::from_utf8(B64.decode(payload).ok()?).ok()?; + let mut fields = decoded.splitn(3, ':'); + let expires = fields.next()?.parse::().ok()?; + let issued_in = fields.next()?.parse::().ok()?; + let id = fields.next().filter(|id| !id.is_empty())?; + (expires > crate::now() && issued_in == epoch).then(|| id.to_string()) +} + +/// How often a session that is in use gets its `last_seen` written down. +/// In between, it is kept in memory only. +const SEEN_EVERY: i64 = 300; + +struct Live { + expires_at: i64, + last_seen: i64, + written: i64, +} + +/// The signed-in sessions, mirrored in memory from the `sessions` table so +/// a request costs no query to check. +#[derive(Default)] +pub struct Sessions { + live: Mutex>, +} + +impl Sessions { + pub async fn load(db: &sqlx::SqlitePool) -> anyhow::Result { + let rows: Vec<(String, i64, i64)> = + sqlx::query_as("SELECT id, expires_at, last_seen FROM sessions WHERE expires_at > ?1") + .bind(crate::now()) + .fetch_all(db) + .await?; + let live = rows + .into_iter() + .map(|(id, expires_at, last_seen)| { + let live = Live { + expires_at, + last_seen, + written: last_seen, + }; + (id, live) + }) + .collect(); + Ok(Self { + live: Mutex::new(live), + }) + } + + /// A new session, for a browser that just logged in. + pub async fn open( + &self, + db: &sqlx::SqlitePool, + user_agent: &str, + expires_at: i64, + ) -> anyhow::Result { + let id = B64.encode(rand::random::<[u8; 16]>()); + let now = crate::now(); + let user_agent: String = user_agent.chars().take(200).collect(); + sqlx::query( + "INSERT INTO sessions (id, created_at, last_seen, expires_at, user_agent) + VALUES (?1, ?2, ?2, ?3, ?4)", + ) + .bind(&id) + .bind(now) + .bind(expires_at) + .bind(&user_agent) + .execute(db) + .await?; + let mut live = self.live.lock().expect("sessions lock"); + live.retain(|_, session| session.expires_at > now); + live.insert( + id.clone(), + Live { + expires_at, + last_seen: now, + written: now, + }, + ); + Ok(id) + } + + /// Whether `id` is signed in, noting that it was just seen. `Some(true)` + /// asks the caller to write that down. + fn seen(&self, id: &str) -> Option { + let now = crate::now(); + let mut live = self.live.lock().expect("sessions lock"); + let session = live.get_mut(id).filter(|s| s.expires_at > now)?; + session.last_seen = now; + let due = now - session.written >= SEEN_EVERY; + if due { + session.written = now; + } + Some(due) + } + + /// When `id` was last seen, fresher than the table may have it. + pub fn last_seen(&self, id: &str) -> Option { + let live = self.live.lock().expect("sessions lock"); + live.get(id).map(|session| session.last_seen) + } + + /// Sign one session out. Says whether there was one to sign out. + pub async fn end(&self, db: &sqlx::SqlitePool, id: &str) -> anyhow::Result { + let removed = sqlx::query("DELETE FROM sessions WHERE id = ?1") + .bind(id) + .execute(db) + .await? + .rows_affected(); + let held = self + .live + .lock() + .expect("sessions lock") + .remove(id) + .is_some(); + Ok(removed > 0 || held) + } + + /// Sign every session out. + pub async fn end_all(&self, db: &sqlx::SqlitePool) -> anyhow::Result<()> { + sqlx::query("DELETE FROM sessions").execute(db).await?; + self.live.lock().expect("sessions lock").clear(); + Ok(()) } - let Ok(decoded) = B64.decode(payload) else { - return false; - }; - let decoded = String::from_utf8_lossy(&decoded); - // A cookie from before epochs existed carries only the expiry, and - // belongs to the first epoch. - let (expires, issued_in) = decoded.split_once(':').unwrap_or((&decoded, "0")); - let (Ok(expires), Ok(issued_in)) = (expires.parse::(), issued_in.parse::()) else { - return false; - }; - expires > crate::now() && issued_in == epoch } /// The session epoch: what every cookie must have been signed under. @@ -82,6 +199,7 @@ impl Epoch { Some((epoch, previous)) if previous == hash => return Ok(epoch), Some((epoch, _)) => { tracing::info!("the login password changed: every device is signed out"); + sqlx::query("DELETE FROM sessions").execute(db).await?; epoch + 1 } }; @@ -96,8 +214,10 @@ impl Epoch { Ok(epoch) } - /// Sign every device out: the epoch moves on, and no cookie has it yet. + /// Sign every device out: the epoch moves on, no cookie has it yet, and + /// no session row is left either. pub async fn advance(state: &AppState) -> anyhow::Result { + state.sessions.end_all(&state.db).await?; let epoch: i64 = sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch") .fetch_one(&state.db) @@ -160,7 +280,10 @@ fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> { } /// Extractor that rejects the request unless a valid session cookie is present. -pub struct Session; +pub struct Session { + /// The `sessions` row the cookie belongs to. + pub id: String, +} impl FromRequestParts for Session { type Rejection = AppError; @@ -172,11 +295,22 @@ impl FromRequestParts for Session { let token = cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?; let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst); - if verify_token(&state.config, token, epoch) { - Ok(Session) - } else { - Err(AppError::Unauthorized) + let id = verify_token(&state.config, token, epoch).ok_or(AppError::Unauthorized)?; + let due = state.sessions.seen(&id).ok_or(AppError::Unauthorized)?; + if due { + let (db, id) = (state.db.clone(), id.clone()); + tokio::spawn(async move { + let written = sqlx::query("UPDATE sessions SET last_seen = ?1 WHERE id = ?2") + .bind(crate::now()) + .bind(&id) + .execute(&db) + .await; + if let Err(e) = written { + tracing::warn!("could not note a session as seen: {e}"); + } + }); } + Ok(Session { id }) } } @@ -242,7 +376,8 @@ mod tests { use axum::http::{HeaderMap, HeaderValue}; use super::{ - clear_cookie, credentials_match, is_same_origin, issue_token, session_cookie, verify_token, + clear_cookie, credentials_match, is_same_origin, issue_token, session_cookie, + session_expiry, verify_token, Sessions, }; use crate::config::Config; @@ -250,24 +385,31 @@ mod tests { Config::for_tests(session_days) } + fn token(config: &Config, epoch: i64) -> String { + issue_token(session_expiry(config), epoch, "sid", config) + } + #[test] fn a_fresh_token_verifies() { let config = config(30); - assert!(verify_token(&config, &issue_token(&config, 0), 0)); + assert_eq!( + verify_token(&config, &token(&config, 0), 0).as_deref(), + Some("sid") + ); } #[test] fn an_expired_token_does_not() { let config = config(-1); - assert!(!verify_token(&config, &issue_token(&config, 0), 0)); + assert!(verify_token(&config, &token(&config, 0), 0).is_none()); } #[test] fn rotating_the_secret_invalidates_every_session() { - let token = issue_token(&config(30), 0); + let token = token(&config(30), 0); let mut rotated = config(30); rotated.secret = b"a-different-secret-entirely".to_vec(); - assert!(!verify_token(&rotated, &token, 0)); + assert!(verify_token(&rotated, &token, 0).is_none()); } #[test] @@ -287,25 +429,25 @@ mod tests { #[test] fn a_tampered_token_does_not_verify() { let config = config(30); - let token = issue_token(&config, 0); + let token = token(&config, 0); let (payload, signature) = token.split_once('.').unwrap(); // A far-future expiry the holder signed themselves. - let forged = base64::Engine::encode(&super::B64, "99999999999"); - assert!(!verify_token(&config, &format!("{forged}.{signature}"), 0)); + let forged = base64::Engine::encode(&super::B64, "99999999999:0:sid"); + assert!(verify_token(&config, &format!("{forged}.{signature}"), 0).is_none()); // The real expiry with the last byte of the signature flipped. let mut bytes = signature.as_bytes().to_vec(); bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' }; let flipped = String::from_utf8(bytes).unwrap(); - assert!(!verify_token(&config, &format!("{payload}.{flipped}"), 0)); + assert!(verify_token(&config, &format!("{payload}.{flipped}"), 0).is_none()); } #[test] fn malformed_tokens_are_rejected_rather_than_panicking() { let config = config(30); for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] { - assert!(!verify_token(&config, token, 0), "{token:?}"); + assert!(verify_token(&config, token, 0).is_none(), "{token:?}"); } } @@ -360,15 +502,72 @@ mod tests { #[test] fn a_cookie_from_an_earlier_epoch_is_refused() { let config = config(30); - let token = issue_token(&config, 3); - assert!(verify_token(&config, &token, 3)); - assert!(!verify_token(&config, &token, 4)); + let token = token(&config, 3); + assert!(verify_token(&config, &token, 3).is_some()); + assert!(verify_token(&config, &token, 4).is_none()); + } - // Issued before epochs: only the expiry, and good in the first one. + #[test] + fn a_cookie_without_a_session_id_is_refused() { + let config = config(30); let expires = crate::now() + 3600; - let payload = base64::Engine::encode(&super::B64, expires.to_string()); - let legacy = format!("{payload}.{}", super::sign(&config.secret, &payload)); - assert!(verify_token(&config, &legacy, 0)); - assert!(!verify_token(&config, &legacy, 1)); + // From before epochs, and from before session rows. + for payload in [ + expires.to_string(), + format!("{expires}:0"), + format!("{expires}:0:"), + ] { + let payload = base64::Engine::encode(&super::B64, payload); + let legacy = format!("{payload}.{}", super::sign(&config.secret, &payload)); + assert!(verify_token(&config, &legacy, 0).is_none(), "{payload}"); + } + } + + async fn db() -> sqlx::SqlitePool { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + db + } + + #[tokio::test] + async fn one_session_ends_and_the_others_stay() { + let db = db().await; + let sessions = Sessions::default(); + let expires = crate::now() + 3600; + let phone = sessions.open(&db, "phone", expires).await.unwrap(); + let laptop = sessions.open(&db, "laptop", expires).await.unwrap(); + assert_ne!(phone, laptop); + assert_eq!(sessions.seen(&phone), Some(false)); + + assert!(sessions.end(&db, &phone).await.unwrap()); + assert_eq!(sessions.seen(&phone), None); + assert!(sessions.seen(&laptop).is_some()); + assert!(!sessions.end(&db, &phone).await.unwrap()); + + // What is kept in memory is what a restart reads back. + let reloaded = Sessions::load(&db).await.unwrap(); + assert_eq!(reloaded.seen(&phone), None); + assert!(reloaded.seen(&laptop).is_some()); + + sessions.end_all(&db).await.unwrap(); + assert_eq!(sessions.seen(&laptop), None); + let left: i64 = sqlx::query_scalar("SELECT count(*) FROM sessions") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(left, 0); + } + + #[tokio::test] + async fn an_expired_session_is_not_signed_in() { + let db = db().await; + let sessions = Sessions::default(); + let gone = sessions.open(&db, "old", crate::now() - 1).await.unwrap(); + assert_eq!(sessions.seen(&gone), None); + assert!(Sessions::load(&db).await.unwrap().seen(&gone).is_none()); } } diff --git a/src/main.rs b/src/main.rs index b57e97b..6ebfd5c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,6 +123,7 @@ async fn main() -> Result<()> { sweep::spawn(db.clone(), config.uploads_dir()); let epoch = auth::Epoch::load(&db, &config).await?; + let sessions = Arc::new(auth::Sessions::load(&db).await?); let vault = vault::Vault::new(db.clone(), config.vault_idle); vault.spawn_idle_lock(); let state = AppState { @@ -134,6 +135,7 @@ async fn main() -> Result<()> { backup, notify, epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), + sessions, }; if let Some(sftp_bind) = config.sftp.bind { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b9e0d53..7f7459c 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -41,6 +41,8 @@ pub fn api_router(max_upload: usize) -> Router { post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)), ) .route("/logout", post(session::logout)) + .route("/sessions", get(session::list)) + .route("/sessions/{id}", delete(session::revoke)) .route("/export", get(export::export)) .route("/backup", get(backup::status).post(backup::run)) .route("/spaces", get(nodes::spaces).post(nodes::create_space)) @@ -140,6 +142,7 @@ pub(crate) mod tests { login_throttle: Arc::default(), vault_throttle: Arc::default(), epoch: Arc::new(AtomicI64::new(0)), + sessions: Arc::default(), config, db, } diff --git a/src/routes/session.rs b/src/routes/session.rs index d15d0f9..b37405e 100644 --- a/src/routes/session.rs +++ b/src/routes/session.rs @@ -1,10 +1,10 @@ use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, http::{header, HeaderMap, StatusCode}, response::IntoResponse, Json, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use crate::{ @@ -46,9 +46,13 @@ pub async fn login( .unwrap_or("unknown"); state.notify.note_login(user_agent).await; + let expires = auth::session_expiry(&state.config); + let id = state.sessions.open(&state.db, user_agent, expires).await?; let token = auth::issue_token( - &state.config, + expires, state.epoch.load(std::sync::atomic::Ordering::SeqCst), + &id, + &state.config, ); Ok(( StatusCode::OK, @@ -75,8 +79,9 @@ pub async fn logout( // Leaving is the moment the vault should not outlive — but only the owner // leaving: anyone may clear a cookie they do not have, and that must not // be a way to shut the vault on the one who does. - if session.is_some() { + if let Some(session) = session { state.vault.lock(); + state.sessions.end(&state.db, &session.id).await?; if query.everywhere { auth::Epoch::advance(&state).await?; tracing::info!("signed out everywhere"); @@ -93,6 +98,59 @@ pub async fn me(_: Session, State(state): State) -> impl IntoResponse Json(json!({ "user": state.config.username })) } +#[derive(Serialize, sqlx::FromRow)] +pub struct SessionSummary { + pub id: String, + pub created_at: i64, + pub last_seen: i64, + pub user_agent: String, + /// The session asking. + #[sqlx(skip)] + pub current: bool, +} + +/// `GET /api/sessions` — every browser signed in, the one seen last first. +pub async fn list( + session: Session, + State(state): State, +) -> AppResult>> { + let mut sessions: Vec = sqlx::query_as( + "SELECT id, created_at, last_seen, user_agent FROM sessions WHERE expires_at > ?1", + ) + .bind(crate::now()) + .fetch_all(&state.db) + .await?; + for summary in &mut sessions { + summary.current = summary.id == session.id; + if let Some(seen) = state.sessions.last_seen(&summary.id) { + summary.last_seen = summary.last_seen.max(seen); + } + } + sessions.sort_by_key(|summary| std::cmp::Reverse(summary.last_seen)); + Ok(Json(sessions)) +} + +/// `DELETE /api/sessions/{id}` — sign one other browser out. The vault is +/// locked as well: it is open for the whole workspace, and the browser just +/// signed out may be the one that opened it. +pub async fn revoke( + session: Session, + State(state): State, + Path(id): Path, +) -> AppResult> { + if id == session.id { + return Err(AppError::BadRequest( + "that is the session you are using — log out to end it".into(), + )); + } + if !state.sessions.end(&state.db, &id).await? { + return Err(AppError::NotFound); + } + state.vault.lock(); + tracing::info!("a session was signed out; the vault is locked"); + Ok(Json(json!({ "ok": true }))) +} + /// `GET /api/health` — for whatever supervises the process. Answers without a /// session and says nothing about the workspace, only that the server is up /// and its database answers. @@ -108,3 +166,136 @@ pub async fn health(State(state): State) -> impl IntoResponse { } } } + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + Router, + }; + use tower::ServiceExt; + + async fn app() -> Router { + let dir = std::env::temp_dir().join(format!("sessions-{}", uuid::Uuid::new_v4())); + let state = crate::routes::tests::state_in(dir).await; + crate::auth::Epoch::load(&state.db, &state.config) + .await + .unwrap(); + crate::routes::api_router(1024).with_state(state) + } + + async fn call( + app: &Router, + method: &str, + uri: &str, + cookie: Option<&str>, + ) -> (StatusCode, String) { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json"); + if let Some(cookie) = cookie { + request = request.header(header::COOKIE, cookie); + } + let body = if uri == "/login" { + r#"{"username":"narl","password":"hunter2"}"# + } else { + "{}" + }; + let response = app + .clone() + .oneshot(request.body(Body::from(body)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(';').next()) + .unwrap_or_default() + .to_string(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + if uri == "/login" { + (status, cookie) + } else { + (status, String::from_utf8_lossy(&body).into_owned()) + } + } + + #[tokio::test] + async fn a_signed_out_session_is_refused_while_the_others_carry_on() { + let app = app().await; + let (_, phone) = call(&app, "POST", "/login", None).await; + let (_, laptop) = call(&app, "POST", "/login", None).await; + assert_ne!(phone, laptop); + + let (status, listed) = call(&app, "GET", "/sessions", Some(&laptop)).await; + assert_eq!(status, StatusCode::OK); + let listed: Vec = serde_json::from_str(&listed).unwrap(); + assert_eq!(listed.len(), 2); + let mine = listed.iter().find(|s| s["current"] == true).unwrap(); + let other = listed.iter().find(|s| s["current"] == false).unwrap(); + + // Not this way for the session asking. + let own = format!("/sessions/{}", mine["id"].as_str().unwrap()); + assert_eq!( + call(&app, "DELETE", &own, Some(&laptop)).await.0, + StatusCode::BAD_REQUEST + ); + + let other = format!("/sessions/{}", other["id"].as_str().unwrap()); + assert_eq!( + call(&app, "DELETE", &other, Some(&laptop)).await.0, + StatusCode::OK + ); + assert_eq!( + call(&app, "GET", "/me", Some(&phone)).await.0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + call(&app, "GET", "/me", Some(&laptop)).await.0, + StatusCode::OK + ); + assert_eq!( + call(&app, "DELETE", &other, Some(&laptop)).await.0, + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn logging_out_everywhere_ends_every_session() { + let app = app().await; + let (_, phone) = call(&app, "POST", "/login", None).await; + let (_, laptop) = call(&app, "POST", "/login", None).await; + + assert_eq!( + call(&app, "POST", "/logout", Some(&phone)).await.0, + StatusCode::OK + ); + assert_eq!( + call(&app, "GET", "/me", Some(&phone)).await.0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + call(&app, "GET", "/me", Some(&laptop)).await.0, + StatusCode::OK + ); + + let (_, tablet) = call(&app, "POST", "/login", None).await; + let everywhere = "/logout?everywhere=true"; + assert_eq!( + call(&app, "POST", everywhere, Some(&tablet)).await.0, + StatusCode::OK + ); + for cookie in [&laptop, &tablet] { + assert_eq!( + call(&app, "GET", "/me", Some(cookie)).await.0, + StatusCode::UNAUTHORIZED + ); + } + } +} diff --git a/src/state.rs b/src/state.rs index b927fb5..f962894 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,7 +3,8 @@ use std::sync::{atomic::AtomicI64, Arc}; use sqlx::SqlitePool; use crate::{ - backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault, + auth::Sessions, backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, + vault::Vault, }; #[derive(Clone)] @@ -23,4 +24,6 @@ pub struct AppState { /// Signed into every session cookie, which must carry the current value /// to be accepted; see [`crate::auth::Epoch`]. pub epoch: Arc, + /// Every signed-in browser; a cookie is good only while its row is here. + pub sessions: Arc, } diff --git a/src/sweep.rs b/src/sweep.rs index ff20d45..2bc20e7 100644 --- a/src/sweep.rs +++ b/src/sweep.rs @@ -60,6 +60,11 @@ impl Swept { pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result { let mut swept = Swept::default(); + sqlx::query("DELETE FROM sessions WHERE expires_at < ?1") + .bind(crate::now()) + .execute(db) + .await?; + // Rows first, so the files they leave are strays by the time the // directory is walked, and go in the same pass. let cutoff = crate::now() - UNREFERENCED_DAYS * 86_400; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9f7eb3d..44f4eb1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -57,6 +57,16 @@ export interface SpaceOverview { recent: NodeSummary[] } +/** A browser that is signed in. */ +export interface SignedIn { + id: string + created_at: number + last_seen: number + user_agent: string + /** The session asking. */ + current: boolean +} + /** A public key allowed to mount the workspace over SFTP. */ export interface SshKey { id: number @@ -460,6 +470,9 @@ export const api = { readShared: (token: string, key: string) => request(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`), + sessions: () => request('/sessions'), + revokeSession: (id: string) => + request<{ ok: true }>(`/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }), listSshKeys: () => request('/ssh-keys'), addSshKey: (name: string, public_key: string) => diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index e6ee8df..8e021a1 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,6 +1,6 @@ import { api, isContainer, type BackupStatus } from './api' import { workspace } from './store.svelte' -import { formatDay, parseDay, parseDue, sealedNote, type ScrollAmount } from './util' +import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util' export type { ScrollAmount } @@ -102,6 +102,33 @@ const ago = (at: number | null) => { return `${Math.round(seconds / 86400)}d ago` } +/** "firefox on linux", out of a user-agent string, as far as it says. */ +function browserOf(agent: string): string { + const browser = /Edg\//.test(agent) + ? 'edge' + : /Firefox\//.test(agent) + ? 'firefox' + : /Chrome\//.test(agent) + ? 'chrome' + : /Safari\//.test(agent) + ? 'safari' + : /curl\//.test(agent) + ? 'curl' + : 'a browser' + const system = /Android/.test(agent) + ? 'android' + : /iPhone|iPad/.test(agent) + ? 'ios' + : /Mac OS X/.test(agent) + ? 'macos' + : /Windows/.test(agent) + ? 'windows' + : /Linux/.test(agent) + ? 'linux' + : '' + return system ? `${browser} on ${system}` : browser +} + const describeBackup = (status: BackupStatus) => { if (!status.configured) { return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error') @@ -782,6 +809,32 @@ export const commands: CommandSpec[] = [ }), }, }, + { + name: 'sessions', + args: '[rm ]', + group: 'view', + help: 'every browser signed in, numbered — rm signs one of them out, and locks the vault', + run: (arg) => + workspace.guard(async () => { + const trimmed = arg.trim() + const sessions = await api.sessions() + if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') { + return workspace.say( + sessions + .map((s, i) => `${i + 1} ${s.current ? 'this one' : browserOf(s.user_agent)}, seen ${relative(s.last_seen)}`) + .join(' · '), + ) + } + const rmMatch = /^rm\s+(\d+)$/.exec(trimmed) + if (!rmMatch) return workspace.say('usage: :sessions [rm ]', 'error') + const target = sessions[Number(rmMatch[1]) - 1] + if (!target) return workspace.say(`no session ${rmMatch[1]} — :sessions lists them`, 'error') + if (target.current) return workspace.say('that is this browser — :logout ends it', 'error') + await api.revokeSession(target.id) + await workspace.loadVault() + workspace.say(`signed out ${browserOf(target.user_agent)}, seen ${relative(target.last_seen)} — the vault is locked`) + }), + }, { name: 'sshkey', args: '[add | rm | vault on|off]',