Sessions are server-side records: random ids, expiry, listed and ended per browser 0024ccb1b050
The cookie holds a random id; the hub keeps only its hash, with the account's salt at sign-in, when it was made and used, and the address and browser. Good while the salt is the account's, used in the last 14 days and under 30 days old. Every sign-in is a new session; signing out deletes the record; Settings > Security lists browsers with Sign out on each; ended sessions are swept nightly. Old name.mac cookies no longer work: one fresh sign-in. Closes #223
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 00:57 UTC
- Signature
- ✔ signed by Claude Code
- Hash
0024ccb1b05029300c29ddf888be7513897fa381fc0e0e7b8661e3f00229d460- Depends on
- 18e381ea88c2 19bb31ff1f5c 34beb1a7e376 3dd6582de060 6a1780558353 83940d53664f 8a3334b7a364 93be9ce4653e b6b7e4cf6b24 c40c2d3edb51 d23464c0ac60
- Depended on by
- a22e4aec2e28 8597fb5ca16c 8dcaeeb848c6 965b950f9b96 c8684d44d549 97b18869acf5 5d90e0e045e2 3ff52534f69b
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 46 atoms, 2 tree operations, 14 files
Browse the tree at this change
added crates/hub/migrations/0068_sessions.sql +15 −0
| 1 | + | -- Sessions as server-side records (oxygenhub#223). The cookie holds a |
|
| 2 | + | -- random id; only its hash is kept here. A session is good while its salt |
|
| 3 | + | -- matches the account's (a new password or "sign out everywhere" ends them |
|
| 4 | + | -- all), it has been used in the last 14 days, and it is under 30 days old. |
|
| 5 | + | CREATE TABLE sessions ( |
|
| 6 | + | id INTEGER PRIMARY KEY, |
|
| 7 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 8 | + | hash TEXT NOT NULL UNIQUE, |
|
| 9 | + | salt TEXT NOT NULL, |
|
| 10 | + | created_ms INTEGER NOT NULL, |
|
| 11 | + | seen_ms INTEGER NOT NULL, |
|
| 12 | + | address TEXT NOT NULL DEFAULT '', |
|
| 13 | + | agent TEXT NOT NULL DEFAULT '' |
|
| 14 | + | ); |
|
| 15 | + | CREATE INDEX sessions_account ON sessions(account_id); |
added crates/hub/src/db/sessions.rs +124 −0
| 1 | + | //! Browser sessions as server-side records (oxygenhub#223). |
|
| 2 | + | ||
| 3 | + | use super::{now_ms, Pool}; |
|
| 4 | + | use sqlx::FromRow; |
|
| 5 | + | ||
| 6 | + | /// A session ends after this long unused. |
|
| 7 | + | pub const IDLE_MS: i64 = 14 * 24 * 60 * 60_000; |
|
| 8 | + | /// And after this long whatever happens. |
|
| 9 | + | pub const ABSOLUTE_MS: i64 = 30 * 24 * 60 * 60_000; |
|
| 10 | + | /// `seen_ms` is written at most this often per session. |
|
| 11 | + | const TOUCH_EVERY_MS: i64 = 60_000; |
|
| 12 | + | ||
| 13 | + | #[derive(Clone, Debug, FromRow)] |
|
| 14 | + | pub struct Session { |
|
| 15 | + | pub id: i64, |
|
| 16 | + | pub account_id: i64, |
|
| 17 | + | pub salt: String, |
|
| 18 | + | pub created_ms: i64, |
|
| 19 | + | pub seen_ms: i64, |
|
| 20 | + | pub address: String, |
|
| 21 | + | pub agent: String, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl Session { |
|
| 25 | + | /// Whether it is still good at `now`, for an account whose salt is `salt`. |
|
| 26 | + | pub fn live(&self, salt: &str, now: i64) -> bool { |
|
| 27 | + | self.salt == salt && now - self.seen_ms < IDLE_MS && now - self.created_ms < ABSOLUTE_MS |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent"; |
|
| 32 | + | ||
| 33 | + | pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> { |
|
| 34 | + | let now = now_ms(); |
|
| 35 | + | let r = sqlx::query("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES (?, ?, ?, ?, ?)") |
|
| 36 | + | .bind(account_id) |
|
| 37 | + | .bind(hash) |
|
| 38 | + | .bind(salt) |
|
| 39 | + | .bind(now) |
|
| 40 | + | .bind(now) |
|
| 41 | + | .execute(pool) |
|
| 42 | + | .await?; |
|
| 43 | + | Ok(r.last_insert_rowid()) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> { |
|
| 47 | + | sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE hash = ?")).bind(hash).fetch_optional(pool).await |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// Note a use: the time (at most once a minute) and, the first time, the |
|
| 51 | + | /// address and browser it came from. |
|
| 52 | + | pub async fn touch(pool: &Pool, s: &Session, address: &str, agent: &str, now: i64) -> sqlx::Result<()> { |
|
| 53 | + | if s.agent.is_empty() && s.address.is_empty() && (!agent.is_empty() || !address.is_empty()) { |
|
| 54 | + | sqlx::query("UPDATE sessions SET seen_ms = ?, address = ?, agent = ? WHERE id = ?") |
|
| 55 | + | .bind(now) |
|
| 56 | + | .bind(address) |
|
| 57 | + | .bind(agent) |
|
| 58 | + | .bind(s.id) |
|
| 59 | + | .execute(pool) |
|
| 60 | + | .await?; |
|
| 61 | + | } else if now - s.seen_ms >= TOUCH_EVERY_MS { |
|
| 62 | + | sqlx::query("UPDATE sessions SET seen_ms = ? WHERE id = ?").bind(now).bind(s.id).execute(pool).await?; |
|
| 63 | + | } |
|
| 64 | + | Ok(()) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | /// An account's live sessions, most recently used first. |
|
| 68 | + | pub async fn of_account(pool: &Pool, account_id: i64, salt: &str) -> sqlx::Result<Vec<Session>> { |
|
| 69 | + | let now = now_ms(); |
|
| 70 | + | let all: Vec<Session> = sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE account_id = ? ORDER BY seen_ms DESC")) |
|
| 71 | + | .bind(account_id) |
|
| 72 | + | .fetch_all(pool) |
|
| 73 | + | .await?; |
|
| 74 | + | Ok(all.into_iter().filter(|s| s.live(salt, now)).collect()) |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | /// End one session of an account's; whether there was one. |
|
| 78 | + | pub async fn end(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<bool> { |
|
| 79 | + | let r = sqlx::query("DELETE FROM sessions WHERE id = ? AND account_id = ?").bind(id).bind(account_id).execute(pool).await?; |
|
| 80 | + | Ok(r.rows_affected() > 0) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | pub async fn end_hash(pool: &Pool, hash: &str) -> sqlx::Result<()> { |
|
| 84 | + | sqlx::query("DELETE FROM sessions WHERE hash = ?").bind(hash).execute(pool).await?; |
|
| 85 | + | Ok(()) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | /// Every session of an account's but `keep`. |
|
| 89 | + | pub async fn end_others(pool: &Pool, account_id: i64, keep: i64) -> sqlx::Result<()> { |
|
| 90 | + | sqlx::query("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(account_id).bind(keep).execute(pool).await?; |
|
| 91 | + | Ok(()) |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | /// Forget sessions that can no longer be used; how many. |
|
| 95 | + | pub async fn sweep(pool: &Pool) -> sqlx::Result<u64> { |
|
| 96 | + | let now = now_ms(); |
|
| 97 | + | let r = sqlx::query( |
|
| 98 | + | "DELETE FROM sessions WHERE seen_ms < ? OR created_ms < ? \ |
|
| 99 | + | OR salt != (SELECT session_salt FROM accounts WHERE accounts.id = sessions.account_id)", |
|
| 100 | + | ) |
|
| 101 | + | .bind(now - IDLE_MS) |
|
| 102 | + | .bind(now - ABSOLUTE_MS) |
|
| 103 | + | .execute(pool) |
|
| 104 | + | .await?; |
|
| 105 | + | Ok(r.rows_affected()) |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | #[cfg(test)] |
|
| 109 | + | mod tests { |
|
| 110 | + | use super::*; |
|
| 111 | + | ||
| 112 | + | #[test] |
|
| 113 | + | fn a_session_ends_on_a_new_salt_when_idle_or_when_old() { |
|
| 114 | + | let t0 = 1_000_000_000_000; |
|
| 115 | + | let s = |
|
| 116 | + | Session { id: 1, account_id: 1, salt: "a".into(), created_ms: t0, seen_ms: t0, address: String::new(), agent: String::new() }; |
|
| 117 | + | assert!(s.live("a", t0 + 1000)); |
|
| 118 | + | assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere"); |
|
| 119 | + | assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days"); |
|
| 120 | + | let busy = Session { seen_ms: t0 + ABSOLUTE_MS - 1000, ..s.clone() }; |
|
| 121 | + | assert!(busy.live("a", t0 + ABSOLUTE_MS - 1)); |
|
| 122 | + | assert!(!busy.live("a", t0 + ABSOLUTE_MS), "30 days, however busy"); |
|
| 123 | + | } |
|
| 124 | + | } |
modified crates/hub/src/auth/mod.rs +7 −5
| … | 89 unchanged lines |
||
| 90 | 90 | hex::encode(blake3::hash(secret.as_bytes()).as_bytes()) |
|
| 91 | 91 | } |
|
| 92 | 92 | ||
| 93 | - | /// The session cookie's value for an account: `username.mac`. |
|
| 94 | - | pub fn session_value(key: &CookieKey, username: &str, session_salt: &str) -> String { |
|
| 95 | - | format!("{username}.{}", key.mac(format!("session:{username}:{session_salt}").as_bytes())) |
|
| 93 | + | /// A new session id for the cookie (oxygenhub#223): random, and kept on |
|
| 94 | + | /// the server only as its [`token_hash`]. |
|
| 95 | + | pub fn new_session_id() -> String { |
|
| 96 | + | random_hex(32) |
|
| 96 | 97 | } |
|
| 97 | 98 | ||
| 98 | 99 | /// Proof, for the code step of a sign-in with two-factor authentication, |
|
| … | 42 unchanged lines |
||
| 141 | 142 | let a = CookieKey::load_or_create(dir.path()).unwrap(); |
|
| 142 | 143 | let b = CookieKey::load_or_create(dir.path()).unwrap(); |
|
| 143 | 144 | assert_eq!(a.mac(b"x"), b.mac(b"x")); |
|
| 144 | - | let v = session_value(&a, "andy", "salt"); |
|
| 145 | - | assert!(v.starts_with("andy.")); |
|
| 145 | + | let v = new_session_id(); |
|
| 146 | + | assert_eq!(v.len(), 64); |
|
| 147 | + | assert_ne!(v, new_session_id()); |
|
| 146 | 148 | assert_ne!(csrf_token(&a, &v), csrf_token(&a, "other")); |
|
| 147 | 149 | } |
|
| 148 | 150 | } |
modified crates/hub/src/auth/viewer.rs +31 −20
| 1 | 1 | //! Who is asking: the session cookie resolved to an account, as an axum |
|
| 2 | 2 | //! extractor every page handler takes. |
|
| 3 | 3 | ||
| 4 | - | use super::{csrf_token, mac_equal, session_value, SESSION_COOKIE}; |
|
| 4 | + | use super::{csrf_token, mac_equal, token_hash, SESSION_COOKIE}; |
|
| 5 | 5 | use crate::db::{self, accounts::Account}; |
|
| 6 | 6 | use crate::Shared; |
|
| 7 | 7 | use axum::extract::FromRequestParts; |
|
| … | 6 unchanged lines |
||
| 14 | 14 | pub account: Account, |
|
| 15 | 15 | /// The cookie value, which CSRF tokens are bound to. |
|
| 16 | 16 | pub session: String, |
|
| 17 | + | /// The session's record (oxygenhub#223): "this browser" in the list. |
|
| 18 | + | pub session_id: i64, |
|
| 17 | 19 | pub csrf: String, |
|
| 18 | 20 | /// Unread notifications, for the header. |
|
| 19 | 21 | pub unread: i64, |
|
| … | 34 unchanged lines |
||
| 54 | 56 | }) |
|
| 55 | 57 | } |
|
| 56 | 58 | ||
| 57 | - | /// Resolve a session cookie value to its account. |
|
| 58 | - | pub async fn resolve(app: &Shared, value: &str) -> Option<ViewerAccount> { |
|
| 59 | - | let (username, mac) = value.rsplit_once('.')?; |
|
| 60 | - | let account = db::accounts::account_by_name(&app.db, username).await.ok()??; |
|
| 61 | - | if account.blocked() { |
|
| 59 | + | /// Resolve a session cookie value to its account: a live session record |
|
| 60 | + | /// (oxygenhub#223) whose salt is still the account's. |
|
| 61 | + | pub async fn resolve(app: &Shared, value: &str) -> Option<(ViewerAccount, db::sessions::Session)> { |
|
| 62 | + | if value.is_empty() || value.len() > 128 { |
|
| 62 | 63 | return None; |
|
| 63 | 64 | } |
|
| 64 | - | let expected = session_value(&app.cookie_key, &account.username, &account.session_salt); |
|
| 65 | - | let (_, expected_mac) = expected.rsplit_once('.')?; |
|
| 66 | - | if !mac_equal(expected_mac, mac) { |
|
| 65 | + | let session = db::sessions::by_hash(&app.db, &token_hash(value)).await.ok()??; |
|
| 66 | + | let account = db::accounts::account_by_id(&app.db, session.account_id).await.ok()??; |
|
| 67 | + | if account.blocked() || !session.live(&account.session_salt, db::now_ms()) { |
|
| 67 | 68 | return None; |
|
| 68 | 69 | } |
|
| 69 | - | let csrf = csrf_token(&app.cookie_key, &expected); |
|
| 70 | + | let csrf = csrf_token(&app.cookie_key, value); |
|
| 70 | 71 | let unread = db::notify::unread(&app.db, account.id).await.unwrap_or(0); |
|
| 71 | - | Some(ViewerAccount { account, session: expected, csrf, unread }) |
|
| 72 | + | let viewer = ViewerAccount { account, session: value.to_string(), session_id: session.id, csrf, unread }; |
|
| 73 | + | Some((viewer, session)) |
|
| 72 | 74 | } |
|
| 73 | 75 | ||
| 74 | 76 | impl FromRequestParts<Shared> for Viewer { |
|
| … | 1 unchanged lines |
||
| 76 | 78 | ||
| 77 | 79 | async fn from_request_parts(parts: &mut Parts, app: &Shared) -> Result<Viewer, Infallible> { |
|
| 78 | 80 | let Some(value) = cookie(&parts.headers, SESSION_COOKIE) else { return Ok(Viewer(None)) }; |
|
| 79 | - | let viewer = resolve(app, &value).await; |
|
| 80 | - | if let Some(v) = &viewer { |
|
| 81 | - | let pool = app.db.clone(); |
|
| 82 | - | let id = v.account.id; |
|
| 83 | - | tokio::spawn(async move { |
|
| 84 | - | let _ = db::accounts::touch_seen(&pool, id).await; |
|
| 85 | - | }); |
|
| 86 | - | } |
|
| 87 | - | Ok(Viewer(viewer)) |
|
| 81 | + | let Some((viewer, session)) = resolve(app, &value).await else { return Ok(Viewer(None)) }; |
|
| 82 | + | let address = crate::api::client_addr(app, &parts.headers, &parts.extensions); |
|
| 83 | + | let agent: String = parts |
|
| 84 | + | .headers |
|
| 85 | + | .get(header::USER_AGENT) |
|
| 86 | + | .and_then(|v| v.to_str().ok()) |
|
| 87 | + | .unwrap_or("") |
|
| 88 | + | .chars() |
|
| 89 | + | .filter(|c| !c.is_control()) |
|
| 90 | + | .take(300) |
|
| 91 | + | .collect(); |
|
| 92 | + | let pool = app.db.clone(); |
|
| 93 | + | let id = viewer.account.id; |
|
| 94 | + | tokio::spawn(async move { |
|
| 95 | + | let _ = db::accounts::touch_seen(&pool, id).await; |
|
| 96 | + | let _ = db::sessions::touch(&pool, &session, &address, &agent, db::now_ms()).await; |
|
| 97 | + | }); |
|
| 98 | + | Ok(Viewer(Some(viewer))) |
|
| 88 | 99 | } |
|
| 89 | 100 | } |
modified crates/hub/src/db/mod.rs +1 −0
| … | 11 unchanged lines |
||
| 12 | 12 | pub mod releases; |
|
| 13 | 13 | pub mod repos; |
|
| 14 | 14 | pub mod runners; |
|
| 15 | + | pub mod sessions; |
|
| 15 | 16 | ||
| 16 | 17 | use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; |
|
| 17 | 18 | use sqlx::SqlitePool; |
|
| … | 52 unchanged lines |
modified crates/hub/src/maintenance.rs +5 −0
| … | 112 unchanged lines |
||
| 113 | 113 | continue; |
|
| 114 | 114 | } |
|
| 115 | 115 | last_day = Some(day); |
|
| 116 | + | match crate::db::sessions::sweep(&app.db).await { |
|
| 117 | + | Ok(n) if n > 0 => tracing::info!("sessions: forgot {n} that had ended"), |
|
| 118 | + | Ok(_) => {} |
|
| 119 | + | Err(e) => tracing::warn!("sessions: sweep failed: {e}"), |
|
| 120 | + | } |
|
| 116 | 121 | let done = gc_all(&app).await; |
|
| 117 | 122 | tracing::info!("gc: nightly run over {} repositories, {} failed", done.len(), done.iter().filter(|d| d.failed).count()); |
|
| 118 | 123 | } |
|
| … | 1 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 99 unchanged lines |
||
| 100 | 100 | .route("/settings/security/totp/enable", post(settings::totp_enable)) |
|
| 101 | 101 | .route("/settings/security/totp/disable", post(settings::totp_disable)) |
|
| 102 | 102 | .route("/settings/security/sessions", post(settings::sessions_reset)) |
|
| 103 | + | .route("/settings/security/sessions/{id}/end", post(settings::session_end)) |
|
| 103 | 104 | .route("/settings/keys", get(settings::keys)) |
|
| 104 | 105 | .route("/settings/keys/upload", post(settings::keys_upload)) |
|
| 105 | 106 | .route("/settings/keys/verify", post(settings::keys_verify)) |
|
| … | 680 unchanged lines |
modified crates/hub/src/web/session.rs +14 −7
| … | 158 unchanged lines |
||
| 159 | 159 | } |
|
| 160 | 160 | app.throttle.lock().unwrap().cleared(&keys[0]); |
|
| 161 | 161 | accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok(); |
|
| 162 | - | Ok(signed_in(&app, &account, &next)) |
|
| 162 | + | Ok(signed_in(&app, &account, &next).await) |
|
| 163 | 163 | } |
|
| 164 | 164 | ||
| 165 | 165 | /// How long the code step of a sign-in may take after the password. |
|
| 166 | 166 | const PENDING_LOGIN_MS: i64 = 5 * 60 * 1000; |
|
| 167 | 167 | ||
| 168 | - | /// A redirect that sets the session cookie. |
|
| 169 | - | pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response { |
|
| 170 | - | let value = auth::session_value(&app.cookie_key, &account.username, &account.session_salt); |
|
| 168 | + | /// A redirect that sets the session cookie: a new session each time, so |
|
| 169 | + | /// signing in (after the code, with two-factor) always rotates it |
|
| 170 | + | /// (oxygenhub#223). |
|
| 171 | + | pub async fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response { |
|
| 172 | + | let value = auth::new_session_id(); |
|
| 173 | + | if let Err(e) = db::sessions::create(&app.db, account.id, &auth::token_hash(&value), &account.session_salt).await { |
|
| 174 | + | tracing::warn!("session for {}: {e}", account.username); |
|
| 175 | + | return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Signing in failed for a moment: try again.").into_response(); |
|
| 176 | + | } |
|
| 171 | 177 | let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; |
|
| 172 | 178 | let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE); |
|
| 173 | 179 | ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response() |
|
| … | 9 unchanged lines |
||
| 183 | 189 | if !viewer.csrf_ok(&f.csrf) { |
|
| 184 | 190 | return Redirect::to("/").into_response(); |
|
| 185 | 191 | } |
|
| 186 | - | if let Some(a) = viewer.account() { |
|
| 187 | - | accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok(); |
|
| 192 | + | if let Some(v) = &viewer.0 { |
|
| 193 | + | db::sessions::end_hash(&app.db, &auth::token_hash(&v.session)).await.ok(); |
|
| 194 | + | accounts::audit(&app.db, Some(&v.account), "logout", &v.account.username, "").await.ok(); |
|
| 188 | 195 | } |
|
| 189 | 196 | let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; |
|
| 190 | 197 | let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}", auth::SESSION_COOKIE); |
|
| … | 106 unchanged lines |
||
| 297 | 304 | let title = format!("New account: {username} signed up on {}", app.config.site.name); |
|
| 298 | 305 | crate::notify::tell(&app, "account.registered", &title, &format!("/{username}"), &username, None, admins).await; |
|
| 299 | 306 | } |
|
| 300 | - | Ok(signed_in(&app, &account, "/")) |
|
| 307 | + | Ok(signed_in(&app, &account, "/").await) |
|
| 301 | 308 | } |
modified crates/hub/src/web/settings.rs +115 −4
| … | 7 unchanged lines |
||
| 8 | 8 | use askama::Template; |
|
| 9 | 9 | use axum::extract::{Form, Multipart, Path, Query, State}; |
|
| 10 | 10 | use axum::http::{header, HeaderMap, StatusCode}; |
|
| 11 | - | use axum::response::{IntoResponse, Response}; |
|
| 11 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 12 | 12 | use oxy_core::identity::{Identity, Signature}; |
|
| 13 | 13 | use serde::Deserialize; |
|
| 14 | 14 | ||
| … | 273 unchanged lines |
||
| 288 | 288 | accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 289 | 289 | accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok(); |
|
| 290 | 290 | let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); |
|
| 291 | - | Ok(super::session::signed_in(&app, &fresh, "/settings/security")) |
|
| 291 | + | Ok(super::session::signed_in(&app, &fresh, "/settings/security").await) |
|
| 292 | 292 | } |
|
| 293 | 293 | ||
| 294 | 294 | #[derive(Template)] |
|
| … | 8 unchanged lines |
||
| 303 | 303 | recovery: Vec<String>, |
|
| 304 | 304 | /// Providers on this hub and the login connected, if any (#106). |
|
| 305 | 305 | connected: Vec<(&'static str, &'static str, Option<String>)>, |
|
| 306 | + | /// Where the account is signed in (oxygenhub#223). |
|
| 307 | + | sessions: Vec<SessionView>, |
|
| 306 | 308 | } |
|
| 307 | 309 | ||
| 310 | + | /// One signed-in browser, as Settings › Security lists it. |
|
| 311 | + | pub struct SessionView { |
|
| 312 | + | pub id: i64, |
|
| 313 | + | pub current: bool, |
|
| 314 | + | pub browser: String, |
|
| 315 | + | pub address: String, |
|
| 316 | + | pub seen_ms: i64, |
|
| 317 | + | pub created_ms: i64, |
|
| 318 | + | } |
|
| 319 | + | ||
| 320 | + | /// "Firefox on Linux" from a User-Agent; "Unknown browser" when it says nothing useful. |
|
| 321 | + | pub fn browser_of(agent: &str) -> String { |
|
| 322 | + | let has = |s: &str| agent.contains(s); |
|
| 323 | + | let browser = if has("Edg/") { |
|
| 324 | + | "Edge" |
|
| 325 | + | } else if has("OPR/") { |
|
| 326 | + | "Opera" |
|
| 327 | + | } else if has("Firefox/") { |
|
| 328 | + | "Firefox" |
|
| 329 | + | } else if has("Chrome/") || has("Chromium/") { |
|
| 330 | + | "Chrome" |
|
| 331 | + | } else if has("Safari/") { |
|
| 332 | + | "Safari" |
|
| 333 | + | } else if has("curl/") { |
|
| 334 | + | "curl" |
|
| 335 | + | } else { |
|
| 336 | + | "" |
|
| 337 | + | }; |
|
| 338 | + | let os = if has("Android") { |
|
| 339 | + | "Android" |
|
| 340 | + | } else if has("iPhone") || has("iPad") { |
|
| 341 | + | "iOS" |
|
| 342 | + | } else if has("Windows") { |
|
| 343 | + | "Windows" |
|
| 344 | + | } else if has("Mac OS X") || has("Macintosh") { |
|
| 345 | + | "macOS" |
|
| 346 | + | } else if has("CrOS") { |
|
| 347 | + | "ChromeOS" |
|
| 348 | + | } else if has("Linux") { |
|
| 349 | + | "Linux" |
|
| 350 | + | } else { |
|
| 351 | + | "" |
|
| 352 | + | }; |
|
| 353 | + | match (browser, os) { |
|
| 354 | + | ("", "") => "Unknown browser".into(), |
|
| 355 | + | (b, "") => b.into(), |
|
| 356 | + | ("", o) => format!("A browser on {o}"), |
|
| 357 | + | (b, o) => format!("{b} on {o}"), |
|
| 358 | + | } |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | async fn sessions_of(app: &Shared, me: &crate::auth::ViewerAccount) -> sqlx::Result<Vec<SessionView>> { |
|
| 362 | + | Ok(crate::db::sessions::of_account(&app.db, me.account.id, &me.account.session_salt) |
|
| 363 | + | .await? |
|
| 364 | + | .into_iter() |
|
| 365 | + | .map(|s| SessionView { |
|
| 366 | + | id: s.id, |
|
| 367 | + | current: s.id == me.session_id, |
|
| 368 | + | browser: browser_of(&s.agent), |
|
| 369 | + | address: s.address, |
|
| 370 | + | seen_ms: s.seen_ms, |
|
| 371 | + | created_ms: s.created_ms, |
|
| 372 | + | }) |
|
| 373 | + | .collect()) |
|
| 374 | + | } |
|
| 375 | + | ||
| 308 | 376 | #[derive(Deserialize)] |
|
| 309 | 377 | pub struct SecurityQuery { |
|
| 310 | 378 | notice: Option<String>, |
|
| … | 4 unchanged lines |
||
| 315 | 383 | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 316 | 384 | let me = require(&viewer, &shell)?; |
|
| 317 | 385 | let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 318 | - | Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected })) |
|
| 386 | + | let sessions = sessions_of(&app, &me).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 387 | + | Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected, sessions })) |
|
| 319 | 388 | } |
|
| 320 | 389 | ||
| 321 | 390 | /// Each sign-in provider this hub offers, with the login connected to the account. |
|
| … | 34 unchanged lines |
||
| 356 | 425 | enrol: Some((secret, uri, qr)), |
|
| 357 | 426 | recovery: vec![], |
|
| 358 | 427 | connected: vec![], |
|
| 428 | + | sessions: vec![], |
|
| 359 | 429 | })) |
|
| 360 | 430 | } |
|
| 361 | 431 | ||
| … | 14 unchanged lines |
||
| 376 | 446 | enrol: Some((f.secret, uri, qr)), |
|
| 377 | 447 | recovery: vec![], |
|
| 378 | 448 | connected: vec![], |
|
| 449 | + | sessions: vec![], |
|
| 379 | 450 | })); |
|
| 380 | 451 | } |
|
| 381 | 452 | let (codes, stored) = totp::new_recovery_codes(); |
|
| … | 8 unchanged lines |
||
| 390 | 461 | enrol: None, |
|
| 391 | 462 | recovery: codes, |
|
| 392 | 463 | connected: vec![], |
|
| 464 | + | sessions: vec![], |
|
| 393 | 465 | })) |
|
| 394 | 466 | } |
|
| 395 | 467 | ||
| … | 10 unchanged lines |
||
| 406 | 478 | enrol: None, |
|
| 407 | 479 | recovery: vec![], |
|
| 408 | 480 | connected: vec![], |
|
| 481 | + | sessions: vec![], |
|
| 409 | 482 | })); |
|
| 410 | 483 | } |
|
| 411 | 484 | accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 7 unchanged lines |
||
| 419 | 492 | enrol: None, |
|
| 420 | 493 | recovery: vec![], |
|
| 421 | 494 | connected: vec![], |
|
| 495 | + | sessions: vec![], |
|
| 422 | 496 | })) |
|
| 423 | 497 | } |
|
| 424 | 498 | ||
| … | 6 unchanged lines |
||
| 431 | 505 | accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 432 | 506 | accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok(); |
|
| 433 | 507 | let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); |
|
| 434 | - | Ok(super::session::signed_in(&app, &fresh, "/settings/security")) |
|
| 508 | + | Ok(super::session::signed_in(&app, &fresh, "/settings/security").await) |
|
| 509 | + | } |
|
| 510 | + | ||
| 511 | + | /// `POST /settings/security/sessions/{id}/end`: sign one browser out. |
|
| 512 | + | pub async fn session_end( |
|
| 513 | + | State(app): State<Shared>, |
|
| 514 | + | viewer: Viewer, |
|
| 515 | + | Path(id): Path<i64>, |
|
| 516 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 517 | + | ) -> Result<Response, Error> { |
|
| 518 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 519 | + | let me = require(&viewer, &shell)?; |
|
| 520 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 521 | + | let ended = crate::db::sessions::end(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 522 | + | if ended { |
|
| 523 | + | accounts::audit(&app.db, Some(&me.account), "session.ended", &me.account.username, &id.to_string()).await.ok(); |
|
| 524 | + | } |
|
| 525 | + | let to = if id == me.session_id { "/" } else { "/settings/security?notice=That+browser+is+signed+out." }; |
|
| 526 | + | Ok(Redirect::to(to).into_response()) |
|
| 435 | 527 | } |
|
| 436 | 528 | ||
| 437 | 529 | // ------------------------------------------------------------------- keys |
|
| … | 261 unchanged lines |
||
| 699 | 791 | accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 700 | 792 | accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok(); |
|
| 701 | 793 | tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await |
|
| 794 | + | } |
|
| 795 | + | ||
| 796 | + | #[cfg(test)] |
|
| 797 | + | mod tests { |
|
| 798 | + | use super::browser_of; |
|
| 799 | + | ||
| 800 | + | #[test] |
|
| 801 | + | fn browsers_are_named_from_their_agent() { |
|
| 802 | + | let ff = "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"; |
|
| 803 | + | let chrome_mac = |
|
| 804 | + | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36"; |
|
| 805 | + | let edge = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36 Edg/129.0"; |
|
| 806 | + | let iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1"; |
|
| 807 | + | assert_eq!(browser_of(ff), "Firefox on Linux"); |
|
| 808 | + | assert_eq!(browser_of(chrome_mac), "Chrome on macOS"); |
|
| 809 | + | assert_eq!(browser_of(edge), "Edge on Windows"); |
|
| 810 | + | assert_eq!(browser_of(iphone), "Safari on iOS"); |
|
| 811 | + | assert_eq!(browser_of(""), "Unknown browser"); |
|
| 812 | + | } |
|
| 702 | 813 | } |
modified crates/hub/src/web/signin.rs +3 −3
| … | 144 unchanged lines |
||
| 145 | 145 | return Ok(with_clear(to_login("This account cannot sign in."))); |
|
| 146 | 146 | } |
|
| 147 | 147 | accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok(); |
|
| 148 | - | return Ok(with_clear(signed_in(&app, &account, "/"))); |
|
| 148 | + | return Ok(with_clear(signed_in(&app, &account, "/").await)); |
|
| 149 | 149 | } |
|
| 150 | 150 | if let Some(email) = &who.email { |
|
| 151 | 151 | if let Some(account) = accounts::account_by_verified_email(&app.db, email).await.map_err(e)? { |
|
| … | 3 unchanged lines |
||
| 155 | 155 | signin::link(&app.db, p.id, &who.subject, account.id, &who.login).await.map_err(e)?; |
|
| 156 | 156 | accounts::audit(&app.db, Some(&account), "signin.connected", &account.username, &format!("{} by email", p.id)).await.ok(); |
|
| 157 | 157 | accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok(); |
|
| 158 | - | return Ok(with_clear(signed_in(&app, &account, "/"))); |
|
| 158 | + | return Ok(with_clear(signed_in(&app, &account, "/").await)); |
|
| 159 | 159 | } |
|
| 160 | 160 | } |
|
| 161 | 161 | let mode = super::session::registration_mode(&app).await; |
|
| … | 5 unchanged lines |
||
| 167 | 167 | )))); |
|
| 168 | 168 | } |
|
| 169 | 169 | let account = register(&app, p, &who).await.map_err(e)?; |
|
| 170 | - | Ok(with_clear(signed_in(&app, &account, "/"))) |
|
| 170 | + | Ok(with_clear(signed_in(&app, &account, "/").await)) |
|
| 171 | 171 | } |
|
| 172 | 172 | ||
| 173 | 173 | /// A new account for someone the provider vouches for: a username from |
|
| … | 125 unchanged lines |
modified crates/hub/tests/accounts.rs +36 −0
| … | 1359 unchanged lines |
||
| 1360 | 1360 | set("", "").await; |
|
| 1361 | 1361 | assert_eq!(get(app, "/security.txt", None).await.status, StatusCode::NOT_FOUND); |
|
| 1362 | 1362 | } |
|
| 1363 | + | ||
| 1364 | + | #[tokio::test] |
|
| 1365 | + | async fn sessions_are_records_that_can_be_listed_ended_and_expire() { |
|
| 1366 | + | let h = hub().await; |
|
| 1367 | + | let app = &h.app; |
|
| 1368 | + | let a = register(app, "andy", "").await; |
|
| 1369 | + | assert_eq!(a.len(), 64, "a random id, not the account's name"); |
|
| 1370 | + | let b = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.expect("signed in again"); |
|
| 1371 | + | assert_ne!(a, b, "each sign-in is a new session"); |
|
| 1372 | + | ||
| 1373 | + | let page = get(app, "/settings/security", Some(&a)).await.body; |
|
| 1374 | + | assert_eq!(page.matches("/end\"").count(), 2, "both browsers listed"); |
|
| 1375 | + | // Each entry is an <li>; the other browser's is the one without the tag. |
|
| 1376 | + | let entries: Vec<&str> = page.split("<li><strong>").skip(1).filter(|e| e.contains("/end\"")).collect(); |
|
| 1377 | + | assert_eq!(entries.iter().filter(|e| e.contains("<span class=\"tag\">this browser</span>")).count(), 1); |
|
| 1378 | + | let other = entries.iter().find(|e| !e.contains("class=\"tag\">this browser")).unwrap(); |
|
| 1379 | + | let other: i64 = other.split("/settings/security/sessions/").nth(1).unwrap().split('/').next().unwrap().parse().unwrap(); |
|
| 1380 | + | let csrf = csrf_of(&page); |
|
| 1381 | + | let r = post(app, &format!("/settings/security/sessions/{other}/end"), Some(&a), &format!("_csrf={csrf}")).await; |
|
| 1382 | + | assert_eq!(r.status, StatusCode::SEE_OTHER); |
|
| 1383 | + | let signed_in = |c: String| async move { get(app, "/settings/security", Some(&c)).await.body.contains("SIGN OUT EVERYWHERE") }; |
|
| 1384 | + | assert!(signed_in(a.clone()).await, "this browser stays"); |
|
| 1385 | + | assert!(!signed_in(b.clone()).await, "that browser is signed out"); |
|
| 1386 | + | ||
| 1387 | + | // Unused for 14 days, it ends. |
|
| 1388 | + | sqlx::query("UPDATE sessions SET seen_ms = seen_ms - 15 * 86400000").execute(&app.db).await.unwrap(); |
|
| 1389 | + | assert!(!signed_in(a.clone()).await, "idle too long"); |
|
| 1390 | + | ||
| 1391 | + | // Signing out ends the record itself, not just the cookie. |
|
| 1392 | + | let c = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.unwrap(); |
|
| 1393 | + | let csrf = csrf_of(&get(app, "/settings/security", Some(&c)).await.body); |
|
| 1394 | + | post(app, "/logout", Some(&c), &format!("_csrf={csrf}")).await; |
|
| 1395 | + | assert!(!signed_in(c).await, "the old cookie no longer works"); |
|
| 1396 | + | // A cookie of the old form (name.mac) is nobody. |
|
| 1397 | + | assert!(!signed_in("andy.0123456789abcdef".into()).await); |
|
| 1398 | + | } |
modified docs/plan.md +1 −1
| … | 18 unchanged lines |
||
| 19 | 19 | ## 0.2 Accounts and access (2026-09-27) |
|
| 20 | 20 | ||
| 21 | 21 | - [x] Schema: accounts, organisations, members, teams, collaborators, tokens, identities, challenges, invitations, settings, audit; pushes carry the pusher. |
|
| 22 | - | - [x] Passwords (Argon2id), signed session cookie bound to a per-account salt, CSRF tokens on every form, same-origin check on every non-GET request, TOTP with recovery codes, sign out everywhere. |
|
| 22 | + | - [x] Passwords (Argon2id), server-side sessions (random id, hash kept; idle and absolute expiry; listed and ended per browser, #223), CSRF tokens on every form, same-origin check on every non-GET request, TOTP with recovery codes, sign out everywhere. |
|
| 23 | 23 | - [x] Registration modes (open, invite, closed; first account is the administrator), invitations, admin page (accounts, ban, promote, registration mode, audit log). |
|
| 24 | 24 | - [x] Roles: owner and site admin → admin; organisation owners → admin, members → the organisation's default role; collaborators (accounts and teams) → their role; visibility public/internal/private decides the anonymous and signed-in floor. A repository the viewer may not read is a 404 everywhere, including the protocol. |
|
| 25 | 25 | - [x] Personal access tokens: hashed, scoped (`repo:read`, `repo:write`, `repo:admin`, `user:read`), optionally limited to repositories, with expiry; the protocol checks role and scope; site tokens from `hub.toml` remain for bootstrapping. |
|
| … | 67 unchanged lines |
modified docs/spec.md +1 −1
| … | 170 unchanged lines |
||
| 171 | 171 | ||
| 172 | 172 | An account has a username (same grammar as an owner name), a display name, an email (verified before it is used for anything), an Argon2id password hash, an optional TOTP secret, a session salt (rotating it signs out every session), and a set of linked Oxygen identities (§5.3). The first account created becomes the site administrator. Registration is open, invitation only, or closed, per configuration. |
|
| 173 | 173 | ||
| 174 | - | Sessions are a signed cookie `oxyhub=<username>.<mac>` where `mac = HMAC-SHA256(cookie_key, "session:" || username || ":" || session_salt)`, `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Password changes and "sign out everywhere" rotate the salt. Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement. |
|
| 174 | + | Sessions are server-side records (oxygenhub#223): the cookie `oxyhub=<id>` holds 32 random bytes in hex, and the hub keeps only the id's BLAKE3 hash, with the account, the account's session salt at sign-in, the times it was made and last used, and the address and browser it came from. A session is good while its salt is still the account's, it has been used in the last 14 days, and it is under 30 days old; the cookie is `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Every sign-in (after the code, with two-factor) makes a new session; signing out deletes its record. Password changes, resets and "sign out everywhere" rotate the salt, which ends every session at once; Settings › Security lists the signed-in browsers and signs any one out. Ended sessions are swept nightly. CSRF tokens are bound to the session id. Sign-in is also slowed against guessing (§15.6, oxygenhub#221). Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement. |
|
| 175 | 175 | ||
| 176 | 176 | #### 5.1.2 Sign in with GitHub or Google (done 2026-10-04) |
|
| 177 | 177 | ||
| … | 582 unchanged lines |
modified templates/settings_security.html +6 −1
| … | 38 unchanged lines |
||
| 39 | 39 | <p class="small">Sign in with these instead of a password.</p> |
|
| 40 | 40 | <ul class="plain">{% for (id, name, login) in connected %}<li>{{ name }}: {% if let Some(l) = login %}<strong>{{ l }}</strong> <form class="tiny inline" method="post" action="/settings/security/disconnect/{{ id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">disconnect</button></form>{% else %}<a href="/settings/security/connect/{{ id }}">connect</a>{% endif %}</li>{% endfor %}</ul> |
|
| 41 | 41 | </section>{% endif %} |
|
| 42 | + | {% if !sessions.is_empty() %}<section class="panel" id="sessions"> |
|
| 43 | + | <h2>Signed in</h2> |
|
| 44 | + | <ul class="plain sessions">{% for x in sessions %}<li><strong>{{ x.browser }}</strong>{% if x.current %} <span class="tag">this browser</span>{% endif %}<span class="small mute">{% if !x.address.is_empty() %} · {{ x.address }}{% endif %} · used {{ x.seen_ms|ago_i }} · signed in {{ x.created_ms|ago_i }}</span> |
|
| 45 | + | <form class="tiny inline" method="post" action="/settings/security/sessions/{{ x.id }}/end"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">sign out</button></form></li>{% endfor %}</ul> |
|
| 46 | + | </section>{% endif %} |
|
| 42 | 47 | <form class="card" method="post" action="/settings/security/sessions"> |
|
| 43 | 48 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 44 | 49 | <h2>Sessions</h2> |
|
| 45 | - | <p class="small">Sign out every browser except this one.</p> |
|
| 50 | + | <p class="small">Sign out every browser except this one. A session ends by itself after 14 days unused, or 30 days after signing in.</p> |
|
| 46 | 51 | <button type="submit">SIGN OUT EVERYWHERE</button> |
|
| 47 | 52 | </form> |
|
| 48 | 53 | {% endblock %} |