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 <n> 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.
This commit is contained in:
nvrl committed 2026-10-20 12:19:26 +02:00
1 parent 6c82264f57
commit 165561f6a2
10 files changed
+540 -56

No files matched your search

+6 -2
View File
@@ -42,7 +42,9 @@ keeps running untouched.
`WORKSPACE_SECRET` signs the session cookie; generate one with `WORKSPACE_SECRET` signs the session cookie; generate one with
`openssl rand -base64 32`. Changing it logs every device out, and so does `openssl rand -base64 32`. Changing it logs every device out, and so does
changing `WORKSPACE_PASSWORD` or `:logout!`, which ends every session but 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 <n>` 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, 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 rather than being taken for its default. The database and
uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is 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 `:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
and lines, `:search!` searches every space, `:set clipboard` / `:set and lines, `:search!` searches every space, `:set clipboard` / `:set
noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the 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! <name>` session, `:logout!` every session and `:sessions rm <n>` one other. The tree has its own: `:space [name]` switches (`:space! <name>`
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>` makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
selected, and `:import` uploads a folder into the one being browsed 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 | | `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 | | `POST` | `/api/login`, `/api/logout` | session; `/api/logout?everywhere=true` ends every one |
| `GET` | `/api/me` | current user | | `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/spaces` | the spaces / create one |
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create | | `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) | | `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) |
+11
View File
@@ -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 ''
);
+247 -48
View File
@@ -1,4 +1,4 @@
use std::convert::Infallible; use std::{collections::HashMap, convert::Infallible, sync::Mutex};
use axum::{ use axum::{
extract::{FromRequestParts, OptionalFromRequestParts, Request}, extract::{FromRequestParts, OptionalFromRequestParts, Request},
@@ -27,36 +27,153 @@ fn sign(secret: &[u8], payload: &str) -> String {
B64.encode(mac.finalize().into_bytes()) B64.encode(mac.finalize().into_bytes())
} }
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no /// A session token is `base64("{expires}:{epoch}:{id}").base64(hmac)`.
/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session. /// The signature keeps anyone from making one up; the id names the
/// A cookie is good until it expires and for as long as the epoch it was /// [`Sessions`] row it belongs to, and a cookie is good only while that row
/// issued under is the current one. /// is there, it has not expired, and the epoch it was issued under is the
pub fn issue_token(config: &Config, epoch: i64) -> String { /// current one. Rotating `WORKSPACE_SECRET` still invalidates every session.
let expires = crate::now() + config.session_days * 86_400; pub fn issue_token(expires: i64, epoch: i64, id: &str, config: &Config) -> String {
let payload = B64.encode(format!("{expires}:{epoch}")); let payload = B64.encode(format!("{expires}:{epoch}:{id}"));
let signature = sign(&config.secret, &payload); let signature = sign(&config.secret, &payload);
format!("{payload}.{signature}") format!("{payload}.{signature}")
} }
pub fn verify_token(config: &Config, token: &str, epoch: i64) -> bool { /// When a session started now runs out.
let Some((payload, signature)) = token.split_once('.') else { pub fn session_expiry(config: &Config) -> i64 {
return false; 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<String> {
let (payload, signature) = token.split_once('.')?;
let expected = sign(&config.secret, payload); let expected = sign(&config.secret, payload);
if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) { 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::<i64>().ok()?;
let issued_in = fields.next()?.parse::<i64>().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<HashMap<String, Live>>,
}
impl Sessions {
pub async fn load(db: &sqlx::SqlitePool) -> anyhow::Result<Self> {
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<String> {
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<bool> {
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<i64> {
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<bool> {
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::<i64>(), issued_in.parse::<i64>()) else {
return false;
};
expires > crate::now() && issued_in == epoch
} }
/// The session epoch: what every cookie must have been signed under. /// 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, previous)) if previous == hash => return Ok(epoch),
Some((epoch, _)) => { Some((epoch, _)) => {
tracing::info!("the login password changed: every device is signed out"); tracing::info!("the login password changed: every device is signed out");
sqlx::query("DELETE FROM sessions").execute(db).await?;
epoch + 1 epoch + 1
} }
}; };
@@ -96,8 +214,10 @@ impl Epoch {
Ok(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<i64> { pub async fn advance(state: &AppState) -> anyhow::Result<i64> {
state.sessions.end_all(&state.db).await?;
let epoch: i64 = let epoch: i64 =
sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch") sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch")
.fetch_one(&state.db) .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. /// 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<AppState> for Session { impl FromRequestParts<AppState> for Session {
type Rejection = AppError; type Rejection = AppError;
@@ -172,11 +295,22 @@ impl FromRequestParts<AppState> for Session {
let token = let token =
cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?; cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?;
let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst); let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
if verify_token(&state.config, token, epoch) { let id = verify_token(&state.config, token, epoch).ok_or(AppError::Unauthorized)?;
Ok(Session) let due = state.sessions.seen(&id).ok_or(AppError::Unauthorized)?;
} else { if due {
Err(AppError::Unauthorized) 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 axum::http::{HeaderMap, HeaderValue};
use super::{ 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; use crate::config::Config;
@@ -250,24 +385,31 @@ mod tests {
Config::for_tests(session_days) Config::for_tests(session_days)
} }
fn token(config: &Config, epoch: i64) -> String {
issue_token(session_expiry(config), epoch, "sid", config)
}
#[test] #[test]
fn a_fresh_token_verifies() { fn a_fresh_token_verifies() {
let config = config(30); 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] #[test]
fn an_expired_token_does_not() { fn an_expired_token_does_not() {
let config = config(-1); let config = config(-1);
assert!(!verify_token(&config, &issue_token(&config, 0), 0)); assert!(verify_token(&config, &token(&config, 0), 0).is_none());
} }
#[test] #[test]
fn rotating_the_secret_invalidates_every_session() { 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); let mut rotated = config(30);
rotated.secret = b"a-different-secret-entirely".to_vec(); rotated.secret = b"a-different-secret-entirely".to_vec();
assert!(!verify_token(&rotated, &token, 0)); assert!(verify_token(&rotated, &token, 0).is_none());
} }
#[test] #[test]
@@ -287,25 +429,25 @@ mod tests {
#[test] #[test]
fn a_tampered_token_does_not_verify() { fn a_tampered_token_does_not_verify() {
let config = config(30); let config = config(30);
let token = issue_token(&config, 0); let token = token(&config, 0);
let (payload, signature) = token.split_once('.').unwrap(); let (payload, signature) = token.split_once('.').unwrap();
// A far-future expiry the holder signed themselves. // A far-future expiry the holder signed themselves.
let forged = base64::Engine::encode(&super::B64, "99999999999"); let forged = base64::Engine::encode(&super::B64, "99999999999:0:sid");
assert!(!verify_token(&config, &format!("{forged}.{signature}"), 0)); assert!(verify_token(&config, &format!("{forged}.{signature}"), 0).is_none());
// The real expiry with the last byte of the signature flipped. // The real expiry with the last byte of the signature flipped.
let mut bytes = signature.as_bytes().to_vec(); let mut bytes = signature.as_bytes().to_vec();
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' }; bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
let flipped = String::from_utf8(bytes).unwrap(); 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] #[test]
fn malformed_tokens_are_rejected_rather_than_panicking() { fn malformed_tokens_are_rejected_rather_than_panicking() {
let config = config(30); let config = config(30);
for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] { 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] #[test]
fn a_cookie_from_an_earlier_epoch_is_refused() { fn a_cookie_from_an_earlier_epoch_is_refused() {
let config = config(30); let config = config(30);
let token = issue_token(&config, 3); let token = token(&config, 3);
assert!(verify_token(&config, &token, 3)); assert!(verify_token(&config, &token, 3).is_some());
assert!(!verify_token(&config, &token, 4)); 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 expires = crate::now() + 3600;
let payload = base64::Engine::encode(&super::B64, expires.to_string()); // From before epochs, and from before session rows.
let legacy = format!("{payload}.{}", super::sign(&config.secret, &payload)); for payload in [
assert!(verify_token(&config, &legacy, 0)); expires.to_string(),
assert!(!verify_token(&config, &legacy, 1)); 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());
} }
} }
+2
View File
@@ -123,6 +123,7 @@ async fn main() -> Result<()> {
sweep::spawn(db.clone(), config.uploads_dir()); sweep::spawn(db.clone(), config.uploads_dir());
let epoch = auth::Epoch::load(&db, &config).await?; 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); let vault = vault::Vault::new(db.clone(), config.vault_idle);
vault.spawn_idle_lock(); vault.spawn_idle_lock();
let state = AppState { let state = AppState {
@@ -134,6 +135,7 @@ async fn main() -> Result<()> {
backup, backup,
notify, notify,
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
sessions,
}; };
if let Some(sftp_bind) = config.sftp.bind { if let Some(sftp_bind) = config.sftp.bind {
+3
View File
@@ -41,6 +41,8 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)), post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)),
) )
.route("/logout", post(session::logout)) .route("/logout", post(session::logout))
.route("/sessions", get(session::list))
.route("/sessions/{id}", delete(session::revoke))
.route("/export", get(export::export)) .route("/export", get(export::export))
.route("/backup", get(backup::status).post(backup::run)) .route("/backup", get(backup::status).post(backup::run))
.route("/spaces", get(nodes::spaces).post(nodes::create_space)) .route("/spaces", get(nodes::spaces).post(nodes::create_space))
@@ -140,6 +142,7 @@ pub(crate) mod tests {
login_throttle: Arc::default(), login_throttle: Arc::default(),
vault_throttle: Arc::default(), vault_throttle: Arc::default(),
epoch: Arc::new(AtomicI64::new(0)), epoch: Arc::new(AtomicI64::new(0)),
sessions: Arc::default(),
config, config,
db, db,
} }
+195 -4
View File
@@ -1,10 +1,10 @@
use axum::{ use axum::{
extract::{Query, State}, extract::{Path, Query, State},
http::{header, HeaderMap, StatusCode}, http::{header, HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
Json, Json,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use crate::{ use crate::{
@@ -46,9 +46,13 @@ pub async fn login(
.unwrap_or("unknown"); .unwrap_or("unknown");
state.notify.note_login(user_agent).await; 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( let token = auth::issue_token(
&state.config, expires,
state.epoch.load(std::sync::atomic::Ordering::SeqCst), state.epoch.load(std::sync::atomic::Ordering::SeqCst),
&id,
&state.config,
); );
Ok(( Ok((
StatusCode::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 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 // 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. // 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.vault.lock();
state.sessions.end(&state.db, &session.id).await?;
if query.everywhere { if query.everywhere {
auth::Epoch::advance(&state).await?; auth::Epoch::advance(&state).await?;
tracing::info!("signed out everywhere"); tracing::info!("signed out everywhere");
@@ -93,6 +98,59 @@ pub async fn me(_: Session, State(state): State<AppState>) -> impl IntoResponse
Json(json!({ "user": state.config.username })) 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<AppState>,
) -> AppResult<Json<Vec<SessionSummary>>> {
let mut sessions: Vec<SessionSummary> = 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<AppState>,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
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 /// `GET /api/health` — for whatever supervises the process. Answers without a
/// session and says nothing about the workspace, only that the server is up /// session and says nothing about the workspace, only that the server is up
/// and its database answers. /// and its database answers.
@@ -108,3 +166,136 @@ pub async fn health(State(state): State<AppState>) -> 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::Value> = 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
);
}
}
}
+4 -1
View File
@@ -3,7 +3,8 @@ use std::sync::{atomic::AtomicI64, Arc};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::{ 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)] #[derive(Clone)]
@@ -23,4 +24,6 @@ pub struct AppState {
/// Signed into every session cookie, which must carry the current value /// Signed into every session cookie, which must carry the current value
/// to be accepted; see [`crate::auth::Epoch`]. /// to be accepted; see [`crate::auth::Epoch`].
pub epoch: Arc<AtomicI64>, pub epoch: Arc<AtomicI64>,
/// Every signed-in browser; a cookie is good only while its row is here.
pub sessions: Arc<Sessions>,
} }
+5
View File
@@ -60,6 +60,11 @@ impl Swept {
pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result<Swept> { pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result<Swept> {
let mut swept = Swept::default(); 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 // Rows first, so the files they leave are strays by the time the
// directory is walked, and go in the same pass. // directory is walked, and go in the same pass.
let cutoff = crate::now() - UNREFERENCED_DAYS * 86_400; let cutoff = crate::now() - UNREFERENCED_DAYS * 86_400;
+13
View File
@@ -57,6 +57,16 @@ export interface SpaceOverview {
recent: NodeSummary[] 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. */ /** A public key allowed to mount the workspace over SFTP. */
export interface SshKey { export interface SshKey {
id: number id: number
@@ -460,6 +470,9 @@ export const api = {
readShared: (token: string, key: string) => readShared: (token: string, key: string) =>
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`), request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
sessions: () => request<SignedIn[]>('/sessions'),
revokeSession: (id: string) =>
request<{ ok: true }>(`/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
listSshKeys: () => request<SshKey[]>('/ssh-keys'), listSshKeys: () => request<SshKey[]>('/ssh-keys'),
addSshKey: (name: string, public_key: string) => addSshKey: (name: string, public_key: string) =>
+54 -1
View File
@@ -1,6 +1,6 @@
import { api, isContainer, type BackupStatus } from './api' import { api, isContainer, type BackupStatus } from './api'
import { workspace } from './store.svelte' 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 } export type { ScrollAmount }
@@ -102,6 +102,33 @@ const ago = (at: number | null) => {
return `${Math.round(seconds / 86400)}d ago` 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) => { const describeBackup = (status: BackupStatus) => {
if (!status.configured) { if (!status.configured) {
return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error') 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 <n>]',
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 <n>]', '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', name: 'sshkey',
args: '[add <name> <key> | rm <name> | vault <name> on|off]', args: '[add <name> <key> | rm <name> | vault <name> on|off]',