A phone as the second factor: Keystore keys approve sign-ins by the number the browser shows, and the sudo step asks a stale session again before sensitive changes (closes #356) a22e4aec2e28
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 88ebf83a57bc
- When
- · 2026-11-01 16:22 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
a22e4aec2e28e7e116863dacacb175a7b7e495581073e1b757840b5ae33ec0b5- Depends on
- 0024ccb1b050 041a945f55f8 119fd17d213f 19bb31ff1f5c 1c52d940b4a0 25ab8c73514a 36e5a9c15a84 3b1bc27fe805 47932fa8d6f3 4dfa4599b3f7 5d818ec7d5fc 83940d53664f 8597fb5ca16c 8a48dd830178 8ad8e656a3a6 8f1d76f4169a 93be9ce4653e 97b18869acf5 b5668108487f bff9106edcba d23464c0ac60 d366306dae7d df71c7892d27
- Depended on by
- 8297a33b9a4c
- In bookmarks
- main
- In tags
- v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 52 atoms, 7 tree operations, 22 files
Browse the tree at this change
added crates/hub/migrations-pg/0104_second_factor.sql +31 −0
| 1 | + | -- Phones as a second factor (oxygenhub#356): a phone's Keystore key, kept |
|
| 2 | + | -- with the sign-in it made (gone when that token is revoked); the |
|
| 3 | + | -- approvals it is asked for, each a number to match; and when each |
|
| 4 | + | -- browser session last proved a second factor, for the sudo step. |
|
| 5 | + | CREATE TABLE authenticators ( |
|
| 6 | + | token_id BIGINT PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE, |
|
| 7 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 8 | + | name TEXT NOT NULL, |
|
| 9 | + | public_key TEXT NOT NULL, |
|
| 10 | + | created_ms BIGINT NOT NULL, |
|
| 11 | + | last_used_ms BIGINT NOT NULL DEFAULT 0 |
|
| 12 | + | ); |
|
| 13 | + | CREATE INDEX authenticators_by_account ON authenticators (account_id); |
|
| 14 | + | ||
| 15 | + | CREATE TABLE approvals ( |
|
| 16 | + | id TEXT PRIMARY KEY, |
|
| 17 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 18 | + | kind TEXT NOT NULL, |
|
| 19 | + | what TEXT NOT NULL, |
|
| 20 | + | number BIGINT NOT NULL, |
|
| 21 | + | choices TEXT NOT NULL, |
|
| 22 | + | session_id BIGINT, |
|
| 23 | + | created_ms BIGINT NOT NULL, |
|
| 24 | + | expires_ms BIGINT NOT NULL, |
|
| 25 | + | state TEXT NOT NULL DEFAULT 'pending', |
|
| 26 | + | decided_ms BIGINT NOT NULL DEFAULT 0, |
|
| 27 | + | decided_by BIGINT |
|
| 28 | + | ); |
|
| 29 | + | CREATE INDEX approvals_by_account ON approvals (account_id, state); |
|
| 30 | + | ||
| 31 | + | ALTER TABLE sessions ADD COLUMN second_factor_ms BIGINT NOT NULL DEFAULT 0; |
added crates/hub/migrations/0104_second_factor.sql +31 −0
| 1 | + | -- Phones as a second factor (oxygenhub#356): a phone's Keystore key, kept |
|
| 2 | + | -- with the sign-in it made (gone when that token is revoked); the |
|
| 3 | + | -- approvals it is asked for, each a number to match; and when each |
|
| 4 | + | -- browser session last proved a second factor, for the sudo step. |
|
| 5 | + | CREATE TABLE authenticators ( |
|
| 6 | + | token_id INTEGER PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE, |
|
| 7 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 8 | + | name TEXT NOT NULL, |
|
| 9 | + | public_key TEXT NOT NULL, |
|
| 10 | + | created_ms INTEGER NOT NULL, |
|
| 11 | + | last_used_ms INTEGER NOT NULL DEFAULT 0 |
|
| 12 | + | ); |
|
| 13 | + | CREATE INDEX authenticators_by_account ON authenticators (account_id); |
|
| 14 | + | ||
| 15 | + | CREATE TABLE approvals ( |
|
| 16 | + | id TEXT PRIMARY KEY, |
|
| 17 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 18 | + | kind TEXT NOT NULL, |
|
| 19 | + | what TEXT NOT NULL, |
|
| 20 | + | number INTEGER NOT NULL, |
|
| 21 | + | choices TEXT NOT NULL, |
|
| 22 | + | session_id INTEGER, |
|
| 23 | + | created_ms INTEGER NOT NULL, |
|
| 24 | + | expires_ms INTEGER NOT NULL, |
|
| 25 | + | state TEXT NOT NULL DEFAULT 'pending', |
|
| 26 | + | decided_ms INTEGER NOT NULL DEFAULT 0, |
|
| 27 | + | decided_by INTEGER |
|
| 28 | + | ); |
|
| 29 | + | CREATE INDEX approvals_by_account ON approvals (account_id, state); |
|
| 30 | + | ||
| 31 | + | ALTER TABLE sessions ADD COLUMN second_factor_ms INTEGER NOT NULL DEFAULT 0; |
added crates/hub/src/api/approvals.rs +156 −0
| 1 | + | //! A phone as a second factor over the API (oxygenhub#356): register this |
|
| 2 | + | //! phone's Keystore key, list and remove phones, and answer the approvals |
|
| 3 | + | //! a browser asks for. Agents' tokens can do none of it. |
|
| 4 | + | ||
| 5 | + | use super::{caller, ApiError, Caller}; |
|
| 6 | + | use crate::auth::{phone, totp}; |
|
| 7 | + | use crate::db::{self, accounts, approvals}; |
|
| 8 | + | use crate::Shared; |
|
| 9 | + | use axum::extract::{Path, State}; |
|
| 10 | + | use axum::http::{HeaderMap, StatusCode}; |
|
| 11 | + | use axum::Json; |
|
| 12 | + | use hub_api::{ApprovalAnswer, Authenticator, NewAuthenticator, PendingApproval}; |
|
| 13 | + | ||
| 14 | + | /// The caller as a person's phone: its account and the token it signed in with. |
|
| 15 | + | fn phone_of(c: &Caller) -> Result<(&accounts::Account, i64), ApiError> { |
|
| 16 | + | let account = c.require("user:read")?; |
|
| 17 | + | if c.agent { |
|
| 18 | + | return Err(ApiError(StatusCode::FORBIDDEN, "an agent cannot be a second factor".into())); |
|
| 19 | + | } |
|
| 20 | + | let Some(id) = c.token_id else { |
|
| 21 | + | return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone uses the token it signed in with".into())); |
|
| 22 | + | }; |
|
| 23 | + | Ok((account, id)) |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// `POST /api/v1/user/authenticators`: this phone as a second factor. It |
|
| 27 | + | /// needs the account's current authenticator-app code, so a token alone |
|
| 28 | + | /// cannot add one, and the app code stays the way in when the phone is lost. |
|
| 29 | + | pub async fn add( |
|
| 30 | + | State(app): State<Shared>, |
|
| 31 | + | headers: HeaderMap, |
|
| 32 | + | Json(r): Json<NewAuthenticator>, |
|
| 33 | + | ) -> Result<(StatusCode, Json<Authenticator>), ApiError> { |
|
| 34 | + | let c = caller(&app, &headers).await?; |
|
| 35 | + | let (account, id) = phone_of(&c)?; |
|
| 36 | + | let Some(secret) = account.totp_secret.as_deref() else { |
|
| 37 | + | return Err(ApiError( |
|
| 38 | + | StatusCode::CONFLICT, |
|
| 39 | + | "set up an authenticator app first (Settings › Security on the hub): it stays the way in if this phone is lost".into(), |
|
| 40 | + | )); |
|
| 41 | + | }; |
|
| 42 | + | if !totp::verify(secret, r.code.trim(), (db::now_ms() / 1000) as u64) { |
|
| 43 | + | accounts::audit(&app.db, Some(account), "2fa.phone_add_failed", &c.token, "wrong code").await.ok(); |
|
| 44 | + | return Err(ApiError(StatusCode::FORBIDDEN, "that code is not right".into())); |
|
| 45 | + | } |
|
| 46 | + | let Some(point) = phone::public_point(&r.public_key) else { |
|
| 47 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "public_key is a P-256 key: hex of its SubjectPublicKeyInfo".into())); |
|
| 48 | + | }; |
|
| 49 | + | approvals::add_authenticator(&app.db, id, account.id, &c.token, &point).await?; |
|
| 50 | + | accounts::audit(&app.db, Some(account), "2fa.phone_added", &c.token, "").await.ok(); |
|
| 51 | + | let a = approvals::authenticator(&app.db, id).await?.expect("just added"); |
|
| 52 | + | Ok((StatusCode::CREATED, Json(Authenticator { id, name: a.name, created_ms: a.created_ms, last_used_ms: a.last_used_ms, this: true }))) |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | /// `GET /api/v1/user/authenticators`: the account's phones. |
|
| 56 | + | pub async fn list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Authenticator>>, ApiError> { |
|
| 57 | + | let c = caller(&app, &headers).await?; |
|
| 58 | + | let account = c.require("user:read")?; |
|
| 59 | + | let this = c.token_id; |
|
| 60 | + | Ok(Json( |
|
| 61 | + | approvals::authenticators(&app.db, account.id) |
|
| 62 | + | .await? |
|
| 63 | + | .into_iter() |
|
| 64 | + | .map(|a| Authenticator { |
|
| 65 | + | this: Some(a.token_id) == this, |
|
| 66 | + | id: a.token_id, |
|
| 67 | + | name: a.name, |
|
| 68 | + | created_ms: a.created_ms, |
|
| 69 | + | last_used_ms: a.last_used_ms, |
|
| 70 | + | }) |
|
| 71 | + | .collect(), |
|
| 72 | + | )) |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | /// `DELETE /api/v1/user/authenticators`: this phone stops being a second factor. |
|
| 76 | + | pub async fn remove(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
|
| 77 | + | let c = caller(&app, &headers).await?; |
|
| 78 | + | let (account, id) = phone_of(&c)?; |
|
| 79 | + | if approvals::remove_authenticator(&app.db, account.id, id).await? { |
|
| 80 | + | accounts::audit(&app.db, Some(account), "2fa.phone_removed", &c.token, "").await.ok(); |
|
| 81 | + | } |
|
| 82 | + | Ok(StatusCode::NO_CONTENT) |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | /// `GET /api/v1/user/approvals`: what waits for this phone's answer. |
|
| 86 | + | pub async fn pending(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<PendingApproval>>, ApiError> { |
|
| 87 | + | let c = caller(&app, &headers).await?; |
|
| 88 | + | let (account, id) = phone_of(&c)?; |
|
| 89 | + | if approvals::authenticator(&app.db, id).await?.is_none() { |
|
| 90 | + | return Ok(Json(Vec::new())); |
|
| 91 | + | } |
|
| 92 | + | Ok(Json( |
|
| 93 | + | approvals::pending(&app.db, account.id, db::now_ms()) |
|
| 94 | + | .await? |
|
| 95 | + | .into_iter() |
|
| 96 | + | .map(|a| PendingApproval { |
|
| 97 | + | choices: a.choices.split(',').filter_map(|n| n.parse().ok()).collect(), |
|
| 98 | + | id: a.id, |
|
| 99 | + | kind: a.kind, |
|
| 100 | + | what: a.what, |
|
| 101 | + | created_ms: a.created_ms, |
|
| 102 | + | expires_ms: a.expires_ms, |
|
| 103 | + | }) |
|
| 104 | + | .collect(), |
|
| 105 | + | )) |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | /// `POST /api/v1/user/approvals/{id}`: this phone's answer. Approving takes |
|
| 109 | + | /// the number the browser shows and the phone key's signature; a wrong |
|
| 110 | + | /// number denies it, as a guess should. |
|
| 111 | + | pub async fn answer( |
|
| 112 | + | State(app): State<Shared>, |
|
| 113 | + | headers: HeaderMap, |
|
| 114 | + | Path(approval): Path<String>, |
|
| 115 | + | Json(r): Json<ApprovalAnswer>, |
|
| 116 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 117 | + | let c = caller(&app, &headers).await?; |
|
| 118 | + | let (account, id) = phone_of(&c)?; |
|
| 119 | + | let Some(key) = approvals::authenticator(&app.db, id).await?.filter(|a| a.account_id == account.id) else { |
|
| 120 | + | return Err(ApiError(StatusCode::FORBIDDEN, "this phone is not a second factor of the account".into())); |
|
| 121 | + | }; |
|
| 122 | + | let now = db::now_ms(); |
|
| 123 | + | let Some(a) = approvals::get(&app.db, &approval).await?.filter(|a| a.account_id == account.id) else { |
|
| 124 | + | return Err(ApiError(StatusCode::NOT_FOUND, "no such approval".into())); |
|
| 125 | + | }; |
|
| 126 | + | if a.state != "pending" || a.expires_ms <= now { |
|
| 127 | + | return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into())); |
|
| 128 | + | } |
|
| 129 | + | let state = if !r.approve { |
|
| 130 | + | "denied" |
|
| 131 | + | } else { |
|
| 132 | + | let Ok(sig) = hex::decode(r.signature.trim()) else { |
|
| 133 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "signature is hex".into())); |
|
| 134 | + | }; |
|
| 135 | + | if !phone::verify(&key.public_key, &phone::message(&a.id, r.number, true), &sig) { |
|
| 136 | + | accounts::audit(&app.db, Some(account), "2fa.bad_signature", &c.token, &a.kind).await.ok(); |
|
| 137 | + | return Err(ApiError(StatusCode::FORBIDDEN, "the signature is not this phone's".into())); |
|
| 138 | + | } |
|
| 139 | + | if r.number == a.number { |
|
| 140 | + | "approved" |
|
| 141 | + | } else { |
|
| 142 | + | "denied" |
|
| 143 | + | } |
|
| 144 | + | }; |
|
| 145 | + | if !approvals::decide(&app.db, &a.id, state, id, now).await? { |
|
| 146 | + | return Err(ApiError(StatusCode::CONFLICT, "that approval was answered already, or ran out of time".into())); |
|
| 147 | + | } |
|
| 148 | + | if state == "approved" { |
|
| 149 | + | if let Some(session) = a.session_id.filter(|_| a.kind == "stepup") { |
|
| 150 | + | db::sessions::mark_second_factor(&app.db, session, now).await?; |
|
| 151 | + | } |
|
| 152 | + | } |
|
| 153 | + | let detail = if r.approve && state == "denied" { format!("{}: wrong number", a.kind) } else { a.kind.clone() }; |
|
| 154 | + | accounts::audit(&app.db, Some(account), &format!("2fa.{state}"), &c.token, &detail).await.ok(); |
|
| 155 | + | Ok(Json(serde_json::json!({ "state": state }))) |
|
| 156 | + | } |
added crates/hub/src/auth/phone.rs +127 −0
| 1 | + | //! The phone as a second factor (oxygenhub#356): a P-256 key made in the |
|
| 2 | + | //! phone's Keystore, usable only after a fingerprint, face or the device |
|
| 3 | + | //! PIN, signs each approval; the browser shows one number and the phone |
|
| 4 | + | //! offers three, so a tap without reading approves nothing. |
|
| 5 | + | ||
| 6 | + | use crate::db::{self, accounts::Account, approvals}; |
|
| 7 | + | use crate::Shared; |
|
| 8 | + | use rand::{seq::SliceRandom, Rng, RngCore}; |
|
| 9 | + | use ring::signature::{UnparsedPublicKey, ECDSA_P256_SHA256_ASN1}; |
|
| 10 | + | ||
| 11 | + | /// How long a phone has to answer. |
|
| 12 | + | pub const APPROVAL_MS: i64 = 2 * 60_000; |
|
| 13 | + | /// How long a proved second factor lets a session past the sudo step. |
|
| 14 | + | pub const FRESH_MS: i64 = 15 * 60_000; |
|
| 15 | + | ||
| 16 | + | /// The DER header of a P-256 SubjectPublicKeyInfo (what Android's |
|
| 17 | + | /// `PublicKey.getEncoded()` gives), before the 65-byte point. |
|
| 18 | + | const P256_SPKI: [u8; 26] = [ |
|
| 19 | + | 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, |
|
| 20 | + | 0x07, 0x03, 0x42, 0x00, |
|
| 21 | + | ]; |
|
| 22 | + | ||
| 23 | + | /// A phone's public key, given as hex of its SubjectPublicKeyInfo or of |
|
| 24 | + | /// the bare uncompressed point: the point, as hex. None when it is |
|
| 25 | + | /// neither, or not on the curve. |
|
| 26 | + | pub fn public_point(hex_key: &str) -> Option<String> { |
|
| 27 | + | let bytes = hex::decode(hex_key.trim()).ok()?; |
|
| 28 | + | let point = match bytes.len() { |
|
| 29 | + | 91 if bytes[..26] == P256_SPKI => &bytes[26..], |
|
| 30 | + | 65 => &bytes[..], |
|
| 31 | + | _ => return None, |
|
| 32 | + | }; |
|
| 33 | + | if point[0] != 0x04 { |
|
| 34 | + | return None; |
|
| 35 | + | } |
|
| 36 | + | // ring checks that the point is on the curve when it verifies, so a |
|
| 37 | + | // bad key only ever fails to verify. |
|
| 38 | + | Some(hex::encode(point)) |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// What the phone signs to answer approval `id`. |
|
| 42 | + | pub fn message(id: &str, number: i64, approve: bool) -> Vec<u8> { |
|
| 43 | + | format!("oxygenhub approval v1\n{id}\n{number}\n{}", if approve { "approve" } else { "deny" }).into_bytes() |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// Whether `signature` (DER, as ECDSA gives it) is the phone's over `msg`. |
|
| 47 | + | pub fn verify(point_hex: &str, msg: &[u8], signature: &[u8]) -> bool { |
|
| 48 | + | let Ok(point) = hex::decode(point_hex) else { return false }; |
|
| 49 | + | UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, point).verify(msg, signature).is_ok() |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | /// Three different two-digit numbers in a random order, and the one the |
|
| 53 | + | /// browser shows. |
|
| 54 | + | pub fn numbers() -> (i64, [i64; 3]) { |
|
| 55 | + | let mut rng = rand::thread_rng(); |
|
| 56 | + | let mut all: Vec<i64> = (10..100).collect(); |
|
| 57 | + | all.shuffle(&mut rng); |
|
| 58 | + | let choices = [all[0], all[1], all[2]]; |
|
| 59 | + | (choices[rng.gen_range(0..3)], choices) |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | fn new_id() -> String { |
|
| 63 | + | let mut b = [0u8; 16]; |
|
| 64 | + | rand::rngs::OsRng.fill_bytes(&mut b); |
|
| 65 | + | hex::encode(b) |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | /// Ask the account's phones to approve `what`: recorded, and sent as an |
|
| 69 | + | /// alert where alerts are on (the app also looks when it opens). For a |
|
| 70 | + | /// step-up, `session_id` is the browser session it makes fresh. |
|
| 71 | + | pub async fn ask(app: &Shared, account: &Account, kind: &str, what: &str, session_id: Option<i64>) -> sqlx::Result<approvals::Approval> { |
|
| 72 | + | let id = new_id(); |
|
| 73 | + | let (number, choices) = numbers(); |
|
| 74 | + | let choices = choices.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(","); |
|
| 75 | + | let what: String = what.chars().take(200).collect(); |
|
| 76 | + | approvals::create( |
|
| 77 | + | &app.db, |
|
| 78 | + | approvals::NewApproval { |
|
| 79 | + | id: &id, |
|
| 80 | + | account_id: account.id, |
|
| 81 | + | kind, |
|
| 82 | + | what: &what, |
|
| 83 | + | number, |
|
| 84 | + | choices: &choices, |
|
| 85 | + | session_id, |
|
| 86 | + | expires_ms: db::now_ms() + APPROVAL_MS, |
|
| 87 | + | }, |
|
| 88 | + | ) |
|
| 89 | + | .await?; |
|
| 90 | + | crate::push::send(app, account.id, crate::push::Alert { kind: "approval".into(), title: what, url: format!("/approvals/{id}") }); |
|
| 91 | + | Ok(approvals::get(&app.db, &id).await?.expect("just made")) |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | #[cfg(test)] |
|
| 95 | + | mod tests { |
|
| 96 | + | use super::*; |
|
| 97 | + | use ring::rand::SystemRandom; |
|
| 98 | + | use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING}; |
|
| 99 | + | ||
| 100 | + | #[test] |
|
| 101 | + | fn a_phone_signature_verifies_and_nothing_else_does() { |
|
| 102 | + | let rng = SystemRandom::new(); |
|
| 103 | + | let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap(); |
|
| 104 | + | let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap(); |
|
| 105 | + | let point = hex::encode(pair.public_key().as_ref()); |
|
| 106 | + | let spki = format!("{}{point}", hex::encode(P256_SPKI)); |
|
| 107 | + | assert_eq!(public_point(&spki).as_deref(), Some(point.as_str()), "the Keystore's encoding"); |
|
| 108 | + | assert_eq!(public_point(&point).as_deref(), Some(point.as_str()), "a bare point"); |
|
| 109 | + | assert_eq!(public_point("00ff"), None); |
|
| 110 | + | let m = message("ab", 47, true); |
|
| 111 | + | let sig = pair.sign(&rng, &m).unwrap(); |
|
| 112 | + | assert!(verify(&point, &m, sig.as_ref())); |
|
| 113 | + | assert!(!verify(&point, &message("ab", 48, true), sig.as_ref()), "another number"); |
|
| 114 | + | assert!(!verify(&point, &message("ab", 47, false), sig.as_ref()), "a denial is not an approval"); |
|
| 115 | + | assert!(!verify(&point, &message("ac", 47, true), sig.as_ref()), "another approval"); |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | #[test] |
|
| 119 | + | fn three_different_numbers_one_shown() { |
|
| 120 | + | for _ in 0..200 { |
|
| 121 | + | let (n, c) = numbers(); |
|
| 122 | + | assert!(c.contains(&n)); |
|
| 123 | + | assert!(c[0] != c[1] && c[1] != c[2] && c[0] != c[2]); |
|
| 124 | + | assert!(c.iter().all(|x| (10..100).contains(x))); |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | } |
added crates/hub/src/db/approvals.rs +163 −0
| 1 | + | //! Phones as a second factor (oxygenhub#356): their keys, and the |
|
| 2 | + | //! approvals they are asked for. |
|
| 3 | + | ||
| 4 | + | use super::{now_ms, Pool}; |
|
| 5 | + | use sqlx::FromRow; |
|
| 6 | + | ||
| 7 | + | /// A phone that approves for its account: the Keystore key of the sign-in |
|
| 8 | + | /// (token) it made, as an uncompressed P-256 point in hex. |
|
| 9 | + | #[derive(Clone, Debug, FromRow)] |
|
| 10 | + | pub struct Authenticator { |
|
| 11 | + | pub token_id: i64, |
|
| 12 | + | pub account_id: i64, |
|
| 13 | + | pub name: String, |
|
| 14 | + | pub public_key: String, |
|
| 15 | + | pub created_ms: i64, |
|
| 16 | + | pub last_used_ms: i64, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | const AUTH_COLUMNS: &str = "token_id, account_id, name, public_key, created_ms, last_used_ms"; |
|
| 20 | + | ||
| 21 | + | /// Register (or replace) the key of the phone signed in with `token_id`. |
|
| 22 | + | pub async fn add_authenticator(pool: &Pool, token_id: i64, account_id: i64, name: &str, public_key: &str) -> sqlx::Result<()> { |
|
| 23 | + | sqlx::query( |
|
| 24 | + | "INSERT INTO authenticators (token_id, account_id, name, public_key, created_ms) VALUES ($1, $2, $3, $4, $5) \ |
|
| 25 | + | ON CONFLICT (token_id) DO UPDATE SET name = excluded.name, public_key = excluded.public_key, created_ms = excluded.created_ms", |
|
| 26 | + | ) |
|
| 27 | + | .bind(token_id) |
|
| 28 | + | .bind(account_id) |
|
| 29 | + | .bind(name) |
|
| 30 | + | .bind(public_key) |
|
| 31 | + | .bind(now_ms()) |
|
| 32 | + | .execute(pool) |
|
| 33 | + | .await?; |
|
| 34 | + | Ok(()) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | pub async fn authenticators(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Authenticator>> { |
|
| 38 | + | sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE account_id = $1 ORDER BY created_ms")) |
|
| 39 | + | .bind(account_id) |
|
| 40 | + | .fetch_all(pool) |
|
| 41 | + | .await |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | pub async fn authenticator(pool: &Pool, token_id: i64) -> sqlx::Result<Option<Authenticator>> { |
|
| 45 | + | sqlx::query_as(&format!("SELECT {AUTH_COLUMNS} FROM authenticators WHERE token_id = $1")).bind(token_id).fetch_optional(pool).await |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /// Remove one of the account's phones; false when it had none by that id. |
|
| 49 | + | pub async fn remove_authenticator(pool: &Pool, account_id: i64, token_id: i64) -> sqlx::Result<bool> { |
|
| 50 | + | Ok(sqlx::query("DELETE FROM authenticators WHERE account_id = $1 AND token_id = $2") |
|
| 51 | + | .bind(account_id) |
|
| 52 | + | .bind(token_id) |
|
| 53 | + | .execute(pool) |
|
| 54 | + | .await? |
|
| 55 | + | .rows_affected() |
|
| 56 | + | > 0) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | /// An approval asked of the account's phones: what for, the number the |
|
| 60 | + | /// page shows among the three the phone offers, and what became of it. |
|
| 61 | + | #[derive(Clone, Debug, FromRow)] |
|
| 62 | + | pub struct Approval { |
|
| 63 | + | pub id: String, |
|
| 64 | + | pub account_id: i64, |
|
| 65 | + | /// `signin` or `stepup`. |
|
| 66 | + | pub kind: String, |
|
| 67 | + | pub what: String, |
|
| 68 | + | pub number: i64, |
|
| 69 | + | /// The three numbers the phone offers, comma-separated. |
|
| 70 | + | pub choices: String, |
|
| 71 | + | /// For a step-up: the browser session it makes fresh. |
|
| 72 | + | pub session_id: Option<i64>, |
|
| 73 | + | pub created_ms: i64, |
|
| 74 | + | pub expires_ms: i64, |
|
| 75 | + | /// `pending`, `approved`, `denied`, or `used` once a sign-in took it. |
|
| 76 | + | pub state: String, |
|
| 77 | + | pub decided_ms: i64, |
|
| 78 | + | pub decided_by: Option<i64>, |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | const COLUMNS: &str = "id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms, state, decided_ms, decided_by"; |
|
| 82 | + | ||
| 83 | + | pub struct NewApproval<'a> { |
|
| 84 | + | pub id: &'a str, |
|
| 85 | + | pub account_id: i64, |
|
| 86 | + | pub kind: &'a str, |
|
| 87 | + | pub what: &'a str, |
|
| 88 | + | pub number: i64, |
|
| 89 | + | pub choices: &'a str, |
|
| 90 | + | pub session_id: Option<i64>, |
|
| 91 | + | pub expires_ms: i64, |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | pub async fn create(pool: &Pool, a: NewApproval<'_>) -> sqlx::Result<()> { |
|
| 95 | + | sqlx::query( |
|
| 96 | + | "INSERT INTO approvals (id, account_id, kind, what, number, choices, session_id, created_ms, expires_ms) \ |
|
| 97 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", |
|
| 98 | + | ) |
|
| 99 | + | .bind(a.id) |
|
| 100 | + | .bind(a.account_id) |
|
| 101 | + | .bind(a.kind) |
|
| 102 | + | .bind(a.what) |
|
| 103 | + | .bind(a.number) |
|
| 104 | + | .bind(a.choices) |
|
| 105 | + | .bind(a.session_id) |
|
| 106 | + | .bind(now_ms()) |
|
| 107 | + | .bind(a.expires_ms) |
|
| 108 | + | .execute(pool) |
|
| 109 | + | .await?; |
|
| 110 | + | Ok(()) |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | pub async fn get(pool: &Pool, id: &str) -> sqlx::Result<Option<Approval>> { |
|
| 114 | + | sqlx::query_as(&format!("SELECT {COLUMNS} FROM approvals WHERE id = $1")).bind(id).fetch_optional(pool).await |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | /// The account's approvals still waiting at `now`, newest first. |
|
| 118 | + | pub async fn pending(pool: &Pool, account_id: i64, now: i64) -> sqlx::Result<Vec<Approval>> { |
|
| 119 | + | sqlx::query_as(&format!( |
|
| 120 | + | "SELECT {COLUMNS} FROM approvals WHERE account_id = $1 AND state = 'pending' AND expires_ms > $2 ORDER BY created_ms DESC" |
|
| 121 | + | )) |
|
| 122 | + | .bind(account_id) |
|
| 123 | + | .bind(now) |
|
| 124 | + | .fetch_all(pool) |
|
| 125 | + | .await |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | /// Decide a pending approval, once: false when it was no longer pending |
|
| 129 | + | /// or had expired. |
|
| 130 | + | pub async fn decide(pool: &Pool, id: &str, state: &str, by: i64, now: i64) -> sqlx::Result<bool> { |
|
| 131 | + | let done = sqlx::query( |
|
| 132 | + | "UPDATE approvals SET state = $1, decided_ms = $2, decided_by = $3 WHERE id = $4 AND state = 'pending' AND expires_ms > $2", |
|
| 133 | + | ) |
|
| 134 | + | .bind(state) |
|
| 135 | + | .bind(now) |
|
| 136 | + | .bind(by) |
|
| 137 | + | .bind(id) |
|
| 138 | + | .execute(pool) |
|
| 139 | + | .await? |
|
| 140 | + | .rows_affected() |
|
| 141 | + | > 0; |
|
| 142 | + | if done && state == "approved" { |
|
| 143 | + | sqlx::query("UPDATE authenticators SET last_used_ms = $1 WHERE token_id = $2").bind(now).bind(by).execute(pool).await?; |
|
| 144 | + | } |
|
| 145 | + | Ok(done) |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | /// Take an approved sign-in approval for the sign-in it was made for, |
|
| 149 | + | /// once: false when it is not approved, or was taken already. |
|
| 150 | + | pub async fn take(pool: &Pool, id: &str, account_id: i64) -> sqlx::Result<bool> { |
|
| 151 | + | Ok(sqlx::query("UPDATE approvals SET state = 'used' WHERE id = $1 AND account_id = $2 AND state = 'approved' AND kind = 'signin'") |
|
| 152 | + | .bind(id) |
|
| 153 | + | .bind(account_id) |
|
| 154 | + | .execute(pool) |
|
| 155 | + | .await? |
|
| 156 | + | .rows_affected() |
|
| 157 | + | > 0) |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | /// Approvals that ended a day ago or more are of no further use. |
|
| 161 | + | pub async fn sweep(pool: &Pool, now: i64) -> sqlx::Result<u64> { |
|
| 162 | + | Ok(sqlx::query("DELETE FROM approvals WHERE expires_ms < $1").bind(now - 86_400_000).execute(pool).await?.rows_affected()) |
|
| 163 | + | } |
added crates/hub/src/web/sudo.rs +271 −0
| 1 | + | //! The sudo step (oxygenhub#356): before a sensitive change (a password, |
|
| 2 | + | //! two-factor settings, keys and tokens, secrets, a repository's deletion |
|
| 3 | + | //! or transfer, the site's administration), a session that has not proved |
|
| 4 | + | //! a second factor for a while proves one again: a phone's approval, a code |
|
| 5 | + | //! from the authenticator app, or the password when the account has no |
|
| 6 | + | //! second factor. Signing in counts as proof. |
|
| 7 | + | ||
| 8 | + | use super::{Error, Page, Shell}; |
|
| 9 | + | use crate::auth::{self, phone, totp, Viewer}; |
|
| 10 | + | use crate::db::{self, accounts}; |
|
| 11 | + | use crate::throttle::{self, Key}; |
|
| 12 | + | use crate::Shared; |
|
| 13 | + | use askama::Template; |
|
| 14 | + | use axum::extract::{Form, Path, Query, State}; |
|
| 15 | + | use axum::http::{header, Method, StatusCode}; |
|
| 16 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 17 | + | use serde::Deserialize; |
|
| 18 | + | ||
| 19 | + | /// Whether a request changes something the sudo step guards. Only |
|
| 20 | + | /// changes are guarded: reading a page never asks. |
|
| 21 | + | pub fn sensitive(method: &Method, path: &str) -> bool { |
|
| 22 | + | if method == Method::GET || method == Method::HEAD { |
|
| 23 | + | return false; |
|
| 24 | + | } |
|
| 25 | + | let p = path.trim_end_matches('/'); |
|
| 26 | + | if p.starts_with("/admin") { |
|
| 27 | + | return true; |
|
| 28 | + | } |
|
| 29 | + | if matches!( |
|
| 30 | + | p, |
|
| 31 | + | "/settings/password" |
|
| 32 | + | | "/settings/security/totp/disable" |
|
| 33 | + | | "/settings/keys/upload" |
|
| 34 | + | | "/settings/keys/unlink" |
|
| 35 | + | | "/settings/tokens" |
|
| 36 | + | | "/settings/backups" |
|
| 37 | + | ) || p.starts_with("/settings/security/phones/") |
|
| 38 | + | || p.starts_with("/settings/security/disconnect/") |
|
| 39 | + | || (p.starts_with("/settings/tokens/") && p.ends_with("/agent")) |
|
| 40 | + | { |
|
| 41 | + | return true; |
|
| 42 | + | } |
|
| 43 | + | let parts: Vec<&str> = p.trim_start_matches('/').split('/').collect(); |
|
| 44 | + | match parts.as_slice() { |
|
| 45 | + | ["orgs", _, "settings", what] => matches!(*what, "secrets" | "storage" | "hooks" | "backups"), |
|
| 46 | + | [owner, _, "settings", what] if !matches!(*owner, "settings" | "admin" | "orgs") => { |
|
| 47 | + | matches!(*what, "delete" | "transfer" | "secrets" | "hooks" | "push-protection" | "secret-bypass" | "backup" | "collaborators") |
|
| 48 | + | } |
|
| 49 | + | _ => false, |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /// The middleware: a guarded change from a session whose proof is older |
|
| 54 | + | /// than [`phone::FRESH_MS`] goes to the sudo page instead, which comes back |
|
| 55 | + | /// to the page it was sent from. |
|
| 56 | + | pub async fn gate(State(app): State<Shared>, req: axum::extract::Request, next: axum::middleware::Next) -> Response { |
|
| 57 | + | if !sensitive(req.method(), req.uri().path()) { |
|
| 58 | + | return next.run(req).await; |
|
| 59 | + | } |
|
| 60 | + | let Some(value) = auth::viewer::cookie(req.headers(), auth::SESSION_COOKIE) else { return next.run(req).await }; |
|
| 61 | + | let Some((_, session)) = auth::viewer::resolve(&app, &value).await else { return next.run(req).await }; |
|
| 62 | + | if db::now_ms() - session.second_factor_ms < phone::FRESH_MS { |
|
| 63 | + | return next.run(req).await; |
|
| 64 | + | } |
|
| 65 | + | let base = app.config.base_url().trim_end_matches('/').to_string(); |
|
| 66 | + | let back = req |
|
| 67 | + | .headers() |
|
| 68 | + | .get(header::REFERER) |
|
| 69 | + | .and_then(|v| v.to_str().ok()) |
|
| 70 | + | .and_then(|r| r.strip_prefix(&base)) |
|
| 71 | + | .filter(|p| p.starts_with('/') && !p.starts_with("//")) |
|
| 72 | + | .unwrap_or("/settings/security") |
|
| 73 | + | .to_string(); |
|
| 74 | + | Redirect::to(&format!("/sudo?next={}", super::settings::urlencode(&back))).into_response() |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | #[derive(Template)] |
|
| 78 | + | #[template(path = "sudo.html")] |
|
| 79 | + | pub struct SudoPage { |
|
| 80 | + | shell: Shell, |
|
| 81 | + | next: String, |
|
| 82 | + | error: Option<String>, |
|
| 83 | + | /// The account has a phone that approves. |
|
| 84 | + | phones: bool, |
|
| 85 | + | /// The account has an authenticator app. |
|
| 86 | + | totp: bool, |
|
| 87 | + | /// Neither: the password stands in. |
|
| 88 | + | password: bool, |
|
| 89 | + | /// Waiting for the phone: the approval and its number. |
|
| 90 | + | approval: Option<(String, i64)>, |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | #[derive(Deserialize)] |
|
| 94 | + | pub struct NextQuery { |
|
| 95 | + | #[serde(default)] |
|
| 96 | + | next: String, |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | fn safe_next(next: &str) -> String { |
|
| 100 | + | if next.starts_with('/') && !next.starts_with("//") { |
|
| 101 | + | next.to_string() |
|
| 102 | + | } else { |
|
| 103 | + | "/".into() |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | async fn page( |
|
| 108 | + | app: &Shared, |
|
| 109 | + | viewer: &Viewer, |
|
| 110 | + | next: &str, |
|
| 111 | + | error: Option<String>, |
|
| 112 | + | approval: Option<(String, i64)>, |
|
| 113 | + | ) -> Result<Response, Error> { |
|
| 114 | + | let shell = Shell::new(app, "/sudo", viewer); |
|
| 115 | + | let Some(me) = viewer.account() else { |
|
| 116 | + | return Ok(Redirect::to(&format!("/login?next={}", super::settings::urlencode(next))).into_response()); |
|
| 117 | + | }; |
|
| 118 | + | let totp = me.totp_secret.is_some(); |
|
| 119 | + | let phones = totp && !db::approvals::authenticators(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty(); |
|
| 120 | + | let password = !totp && !me.password_hash.is_empty(); |
|
| 121 | + | Ok(Page(SudoPage { shell, next: safe_next(next), error, phones, totp, password, approval }).into_response()) |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | /// `GET /sudo?next=`: prove it is you, then carry on. |
|
| 125 | + | pub async fn show(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NextQuery>) -> Result<Response, Error> { |
|
| 126 | + | page(&app, &viewer, &q.next, None, None).await |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | #[derive(Deserialize)] |
|
| 130 | + | pub struct SudoForm { |
|
| 131 | + | #[serde(rename = "_csrf")] |
|
| 132 | + | csrf: String, |
|
| 133 | + | #[serde(default)] |
|
| 134 | + | next: String, |
|
| 135 | + | #[serde(default)] |
|
| 136 | + | code: String, |
|
| 137 | + | #[serde(default)] |
|
| 138 | + | password: String, |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | /// `POST /sudo`: a code from the authenticator app (or a recovery code), |
|
| 142 | + | /// or the password for an account without one. |
|
| 143 | + | pub async fn confirm(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SudoForm>) -> Result<Response, Error> { |
|
| 144 | + | let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) }; |
|
| 145 | + | let shell = Shell::new(&app, "/sudo", &viewer); |
|
| 146 | + | if !viewer.csrf_ok(&f.csrf) { |
|
| 147 | + | return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await; |
|
| 148 | + | } |
|
| 149 | + | let now = db::now_ms(); |
|
| 150 | + | let keys = [Key::account("sudo", &me.account.username)]; |
|
| 151 | + | let wait = app.throttle.lock().unwrap().wait(&keys, now); |
|
| 152 | + | if let Some(wait) = wait { |
|
| 153 | + | return page(&app, &viewer, &f.next, Some(throttle::message(wait)), None).await; |
|
| 154 | + | } |
|
| 155 | + | let ok = match &me.account.totp_secret { |
|
| 156 | + | Some(secret) => { |
|
| 157 | + | totp::verify(secret, f.code.trim(), (now / 1000) as u64) |
|
| 158 | + | || match totp::use_recovery_code(&me.account.recovery_codes, f.code.trim()) { |
|
| 159 | + | Some(rest) => { |
|
| 160 | + | accounts::set_totp(&app.db, me.account.id, Some(secret), &rest) |
|
| 161 | + | .await |
|
| 162 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 163 | + | true |
|
| 164 | + | } |
|
| 165 | + | None => false, |
|
| 166 | + | } |
|
| 167 | + | } |
|
| 168 | + | None => !me.account.password_hash.is_empty() && auth::verify_password(&me.account.password_hash, &f.password), |
|
| 169 | + | }; |
|
| 170 | + | if !ok { |
|
| 171 | + | app.throttle.lock().unwrap().failed(&keys, now); |
|
| 172 | + | accounts::audit(&app.db, Some(&me.account), "sudo.failed", &me.account.username, "").await.ok(); |
|
| 173 | + | let what = if me.account.totp_secret.is_some() { "That code is not right." } else { "That password is not right." }; |
|
| 174 | + | return page(&app, &viewer, &f.next, Some(what.into()), None).await; |
|
| 175 | + | } |
|
| 176 | + | app.throttle.lock().unwrap().cleared(&keys[0]); |
|
| 177 | + | db::sessions::mark_second_factor(&app.db, me.session_id, now).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 178 | + | accounts::audit( |
|
| 179 | + | &app.db, |
|
| 180 | + | Some(&me.account), |
|
| 181 | + | "sudo", |
|
| 182 | + | &me.account.username, |
|
| 183 | + | if me.account.totp_secret.is_some() { "code" } else { "password" }, |
|
| 184 | + | ) |
|
| 185 | + | .await |
|
| 186 | + | .ok(); |
|
| 187 | + | Ok(Redirect::to(&safe_next(&f.next)).into_response()) |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | #[derive(Deserialize)] |
|
| 191 | + | pub struct PhoneForm { |
|
| 192 | + | #[serde(rename = "_csrf")] |
|
| 193 | + | csrf: String, |
|
| 194 | + | #[serde(default)] |
|
| 195 | + | next: String, |
|
| 196 | + | } |
|
| 197 | + | ||
| 198 | + | /// `POST /sudo/phone`: ask the account's phones; the page waits for one. |
|
| 199 | + | pub async fn ask_phone( |
|
| 200 | + | State(app): State<Shared>, |
|
| 201 | + | viewer: Viewer, |
|
| 202 | + | headers: axum::http::HeaderMap, |
|
| 203 | + | Form(f): Form<PhoneForm>, |
|
| 204 | + | ) -> Result<Response, Error> { |
|
| 205 | + | let Some(me) = viewer.0.clone() else { return Ok(Redirect::to("/login").into_response()) }; |
|
| 206 | + | let shell = Shell::new(&app, "/sudo", &viewer); |
|
| 207 | + | if !viewer.csrf_ok(&f.csrf) { |
|
| 208 | + | return page(&app, &viewer, &f.next, Some("That form had expired: try again.".into()), None).await; |
|
| 209 | + | } |
|
| 210 | + | let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or(""); |
|
| 211 | + | let what = format!("Confirm a change from {}", super::settings::browser_of(agent)); |
|
| 212 | + | let a = phone::ask(&app, &me.account, "stepup", &what, Some(me.session_id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 213 | + | page(&app, &viewer, &f.next, None, Some((a.id, a.number))).await |
|
| 214 | + | } |
|
| 215 | + | ||
| 216 | + | /// `GET /sudo/approval/{id}`: what became of this session's approval. |
|
| 217 | + | pub async fn approval_state(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Response { |
|
| 218 | + | let found = match viewer.0.as_ref() { |
|
| 219 | + | Some(me) => { |
|
| 220 | + | db::approvals::get(&app.db, &id).await.ok().flatten().filter(|a| a.session_id == Some(me.session_id) && a.kind == "stepup") |
|
| 221 | + | } |
|
| 222 | + | None => None, |
|
| 223 | + | }; |
|
| 224 | + | match found { |
|
| 225 | + | Some(a) => { |
|
| 226 | + | let state = if a.state == "pending" && a.expires_ms <= db::now_ms() { "expired".to_string() } else { a.state }; |
|
| 227 | + | axum::Json(serde_json::json!({ "state": state })).into_response() |
|
| 228 | + | } |
|
| 229 | + | None => (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(), |
|
| 230 | + | } |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | #[cfg(test)] |
|
| 234 | + | mod tests { |
|
| 235 | + | use super::*; |
|
| 236 | + | ||
| 237 | + | #[test] |
|
| 238 | + | fn only_changes_that_matter_are_guarded() { |
|
| 239 | + | let post = Method::POST; |
|
| 240 | + | for p in [ |
|
| 241 | + | "/settings/password", |
|
| 242 | + | "/settings/security/totp/disable", |
|
| 243 | + | "/settings/security/phones/3/remove", |
|
| 244 | + | "/settings/tokens", |
|
| 245 | + | "/settings/tokens/4/agent", |
|
| 246 | + | "/settings/keys/upload", |
|
| 247 | + | "/admin/mail", |
|
| 248 | + | "/admin/runners/7/state", |
|
| 249 | + | "/orgs/acme/settings/secrets", |
|
| 250 | + | "/andy/demo/settings/delete", |
|
| 251 | + | "/andy/demo/settings/transfer", |
|
| 252 | + | "/andy/demo/settings/secrets", |
|
| 253 | + | "/andy/demo/settings/collaborators", |
|
| 254 | + | ] { |
|
| 255 | + | assert!(sensitive(&post, p), "{p}"); |
|
| 256 | + | } |
|
| 257 | + | for p in [ |
|
| 258 | + | "/settings", |
|
| 259 | + | "/settings/appearance", |
|
| 260 | + | "/settings/security/totp/begin", |
|
| 261 | + | "/settings/tokens/4/delete", |
|
| 262 | + | "/andy/demo/issues/new", |
|
| 263 | + | "/andy/demo/settings/topics", |
|
| 264 | + | "/login", |
|
| 265 | + | "/sudo", |
|
| 266 | + | ] { |
|
| 267 | + | assert!(!sensitive(&post, p), "{p}"); |
|
| 268 | + | } |
|
| 269 | + | assert!(!sensitive(&Method::GET, "/admin/mail"), "reading never asks"); |
|
| 270 | + | } |
|
| 271 | + | } |
added templates/sudo.html +40 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Confirm it is you{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Confirm it is you</h1></div> |
|
| 5 | + | <p class="mute">This change needs a fresh proof that it is you. It lasts 15 minutes; then go back and make the change again.</p> |
|
| 6 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 7 | + | {% if let Some((id, number)) = approval %} |
|
| 8 | + | <section class="card-box"> |
|
| 9 | + | <div class="approve-wait pad" data-approval-poll="/sudo/approval/{{ id }}" data-approval-next="{{ next }}" aria-live="polite"> |
|
| 10 | + | <p>Open OxygenHub on your phone and pick</p> |
|
| 11 | + | <p class="approve-number" aria-label="the number {{ number }}">{{ number }}</p> |
|
| 12 | + | <p class="small mute" data-approval-status>Waiting for your phone…</p> |
|
| 13 | + | </div> |
|
| 14 | + | </section> |
|
| 15 | + | {% else %} |
|
| 16 | + | {% if phones %}<section class="card-box"> |
|
| 17 | + | <form class="pad sec-form sec-split" method="post" action="/sudo/phone"> |
|
| 18 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 19 | + | <input type="hidden" name="next" value="{{ next }}"> |
|
| 20 | + | <p>Approve on your phone, with your fingerprint, face or PIN.</p> |
|
| 21 | + | <button type="submit">APPROVE ON MY PHONE</button> |
|
| 22 | + | </form> |
|
| 23 | + | </section>{% endif %} |
|
| 24 | + | {% endif %} |
|
| 25 | + | <section class="card-box"> |
|
| 26 | + | <form class="pad sec-form" method="post" action="/sudo"> |
|
| 27 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 28 | + | <input type="hidden" name="next" value="{{ next }}"> |
|
| 29 | + | {% if totp %} |
|
| 30 | + | <div class="sec-row"><label for="code">{% if phones %}Or a code{% else %}A code{% endif %} from your authenticator app, or a recovery code</label><input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code"{% if !phones %} autofocus{% endif %} required><button type="submit">CONFIRM</button></div> |
|
| 31 | + | {% else if password %} |
|
| 32 | + | <div class="sec-row"><label for="password">Your password</label><input type="password" id="password" name="password" autocomplete="current-password" autofocus required><button type="submit">CONFIRM</button></div> |
|
| 33 | + | <p class="small mute">With two-factor authentication on (Settings › Security), a code or your phone confirms instead.</p> |
|
| 34 | + | {% else %} |
|
| 35 | + | <p>Your account signs in through another service and has no password here: sign out and sign in again to confirm.</p> |
|
| 36 | + | {% endif %} |
|
| 37 | + | </form> |
|
| 38 | + | </section> |
|
| 39 | + | <p class="small"><a href="{{ next }}">Go back</a></p> |
|
| 40 | + | {% endblock %} |
modified CHANGELOG.md +4 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## Unreleased |
|
| 6 | + | ||
| 7 | + | - **Added: your phone as the second factor, and the sudo step (#356).** With two-factor sign-in on, the OxygenHub app can add the phone as a second factor (its Keystore key, opened by your fingerprint, face or PIN; adding it needs a current app code). Signing in then shows a two-digit number and waits: the phone offers three, you pick the one shown and confirm, and the browser carries on, with "Use a code instead" always there. A wrong number is a no. Before sensitive changes (password, two-factor settings, keys, tokens, secrets, webhooks, collaborators, deleting or transferring a repository, anything under Administration) a session that has not proved a second factor in 15 minutes is asked again at `/sudo`: the phone, a code, or the password for an account without two-factor. Settings › Security lists the phones; turning app codes off removes them. API: `/api/v1/user/authenticators`, `/api/v1/user/approvals`; migration 0104. |
|
| 8 | + | ||
| 5 | 9 | ## 0.9.20 (2026-11-01) |
|
| 6 | 10 | ||
| 7 | 11 | - **Added: `GET /api/v1/user/issues` (#359).** The open issues assigned to you, in repositories you can see, newest-updated first, as the dashboard's "Assigned to you" lists them, each with its repository and address; for apps, first the Android widget's issue count (oxygenhub-android#31). |
|
| … | 439 unchanged lines |
modified assets/hub.js +43 −0
| … | 975 unchanged lines |
||
| 976 | 976 | var link = nav.querySelector('a[data-slug="' + (moved[hash] || hash) + '"]'); |
|
| 977 | 977 | if (link && link.getAttribute("aria-current") !== "page") location.replace(link.getAttribute("href") + "#" + hash); |
|
| 978 | 978 | })(); |
|
| 979 | + | /* Waiting for a phone's approval (oxygenhub#356): at sign-in the page |
|
| 980 | + | posts its proof of the password and signs in once the phone approves; |
|
| 981 | + | at the sudo step it asks with the session and goes back where it came |
|
| 982 | + | from. A no, or too long a wait, says so and stops. */ |
|
| 983 | + | (function () { |
|
| 984 | + | var box = document.querySelector("[data-approval-poll]"); |
|
| 985 | + | if (!box) return; |
|
| 986 | + | var status = box.querySelector("[data-approval-status]"); |
|
| 987 | + | var form = box.closest("form"); |
|
| 988 | + | var url = box.getAttribute("data-approval-poll"); |
|
| 989 | + | var said = { |
|
| 990 | + | denied: "Your phone said no, or a different number was picked. Sign in again, or use a code.", |
|
| 991 | + | expired: "That took too long. Start again, or use a code.", |
|
| 992 | + | unknown: "That approval is gone. Start again." |
|
| 993 | + | }; |
|
| 994 | + | var tries = 0; |
|
| 995 | + | function ask() { |
|
| 996 | + | var req; |
|
| 997 | + | if (form) { |
|
| 998 | + | var body = new URLSearchParams(); |
|
| 999 | + | ["username", "pending", "approval"].forEach(function (n) { |
|
| 1000 | + | var el = form.querySelector('input[name="' + n + '"]'); |
|
| 1001 | + | if (el) body.set(n, el.value); |
|
| 1002 | + | }); |
|
| 1003 | + | req = fetch(url, { method: "POST", credentials: "same-origin", body: body }); |
|
| 1004 | + | } else { |
|
| 1005 | + | req = fetch(url, { credentials: "same-origin", headers: { accept: "application/json" } }); |
|
| 1006 | + | } |
|
| 1007 | + | req.then(function (r) { return r.json(); }).then(function (j) { |
|
| 1008 | + | var state = j && j.state; |
|
| 1009 | + | box.setAttribute("data-state", state || ""); |
|
| 1010 | + | if (state === "approved") { |
|
| 1011 | + | if (status) status.textContent = "Approved. Carrying on…"; |
|
| 1012 | + | if (form) form.submit(); else location.href = box.getAttribute("data-approval-next") || "/"; |
|
| 1013 | + | } else if (said[state] || state === "used") { |
|
| 1014 | + | if (status) status.textContent = said[state] || said.unknown; |
|
| 1015 | + | } else if (++tries < 75) { |
|
| 1016 | + | setTimeout(ask, 2000); |
|
| 1017 | + | } |
|
| 1018 | + | }).catch(function () { if (++tries < 75) setTimeout(ask, 4000); }); |
|
| 1019 | + | } |
|
| 1020 | + | setTimeout(ask, 1500); |
|
| 1021 | + | })(); |
|
| 979 | 1022 | })(); |
modified crates/hub-api/src/lib.rs +55 −0
| … | 893 unchanged lines |
||
| 894 | 894 | pub url: String, |
|
| 895 | 895 | } |
|
| 896 | 896 | ||
| 897 | + | /// `POST /user/authenticators` (oxygenhub#356): this phone as a second |
|
| 898 | + | /// factor. `public_key` is hex of the Keystore key's SubjectPublicKeyInfo |
|
| 899 | + | /// (or its bare uncompressed P-256 point); `code` is the account's current |
|
| 900 | + | /// authenticator-app code, which adding a phone needs. |
|
| 901 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 902 | + | pub struct NewAuthenticator { |
|
| 903 | + | pub public_key: String, |
|
| 904 | + | pub code: String, |
|
| 905 | + | } |
|
| 906 | + | ||
| 907 | + | /// A phone that approves sign-ins and sensitive actions for its account. |
|
| 908 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 909 | + | pub struct Authenticator { |
|
| 910 | + | /// The sign-in (token) it belongs to: signing that phone out removes it. |
|
| 911 | + | pub id: i64, |
|
| 912 | + | pub name: String, |
|
| 913 | + | pub created_ms: i64, |
|
| 914 | + | /// When it last approved something; 0 for never. |
|
| 915 | + | pub last_used_ms: i64, |
|
| 916 | + | /// The caller's own phone. |
|
| 917 | + | #[serde(default)] |
|
| 918 | + | pub this: bool, |
|
| 919 | + | } |
|
| 920 | + | ||
| 921 | + | /// An approval waiting for the caller's phone (`GET /user/approvals`). |
|
| 922 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 923 | + | pub struct PendingApproval { |
|
| 924 | + | pub id: String, |
|
| 925 | + | /// `signin` or `stepup`. |
|
| 926 | + | pub kind: String, |
|
| 927 | + | /// What it is for: "Sign in from Firefox on Linux, 203.0.113.9". |
|
| 928 | + | pub what: String, |
|
| 929 | + | /// The three numbers to offer; the browser shows one of them. |
|
| 930 | + | pub choices: Vec<i64>, |
|
| 931 | + | pub created_ms: i64, |
|
| 932 | + | pub expires_ms: i64, |
|
| 933 | + | } |
|
| 934 | + | ||
| 935 | + | /// `POST /user/approvals/{id}`: the phone's answer. To approve, `number` |
|
| 936 | + | /// is the one picked and `signature` the hex DER ECDSA signature, by the |
|
| 937 | + | /// phone's key, over `oxygenhub approval v1\n{id}\n{number}\napprove`. |
|
| 938 | + | /// A denial needs neither. |
|
| 939 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 940 | + | pub struct ApprovalAnswer { |
|
| 941 | + | pub approve: bool, |
|
| 942 | + | #[serde(default)] |
|
| 943 | + | pub number: i64, |
|
| 944 | + | #[serde(default)] |
|
| 945 | + | pub signature: String, |
|
| 946 | + | } |
|
| 947 | + | ||
| 897 | 948 | /// `GET /user/proposals` query. |
|
| 898 | 949 | #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] |
|
| 899 | 950 | pub struct UserProposalsQuery { |
|
| … | 690 unchanged lines |
||
| 1590 | 1641 | ("UserProposals", schemars::schema_for!(UserProposals)), |
|
| 1591 | 1642 | ("ProposalBrief", schemars::schema_for!(ProposalBrief)), |
|
| 1592 | 1643 | ("IssueBrief", schemars::schema_for!(IssueBrief)), |
|
| 1644 | + | ("NewAuthenticator", schemars::schema_for!(NewAuthenticator)), |
|
| 1645 | + | ("Authenticator", schemars::schema_for!(Authenticator)), |
|
| 1646 | + | ("PendingApproval", schemars::schema_for!(PendingApproval)), |
|
| 1647 | + | ("ApprovalAnswer", schemars::schema_for!(ApprovalAnswer)), |
|
| 1593 | 1648 | ("StarterAssistants", schemars::schema_for!(StarterAssistants)), |
|
| 1594 | 1649 | ("StarterStatus", schemars::schema_for!(StarterStatus)), |
|
| 1595 | 1650 | ("StarterRequest", schemars::schema_for!(StarterRequest)), |
|
| … | 300 unchanged lines |
modified crates/hub/src/api/mod.rs +4 −0
| … | 14 unchanged lines |
||
| 15 | 15 | use hub_api::*; |
|
| 16 | 16 | use serde::Deserialize; |
|
| 17 | 17 | ||
| 18 | + | mod approvals; |
|
| 18 | 19 | mod attachments; |
|
| 19 | 20 | mod backups; |
|
| 20 | 21 | pub mod device; |
|
| … | 90 unchanged lines |
||
| 111 | 112 | .route("/user/orgs", get(user_orgs)) |
|
| 112 | 113 | .route("/user/proposals", get(user_proposals)) |
|
| 113 | 114 | .route("/user/issues", get(user_issues)) |
|
| 115 | + | .route("/user/authenticators", get(approvals::list).post(approvals::add).delete(approvals::remove)) |
|
| 116 | + | .route("/user/approvals", get(approvals::pending)) |
|
| 117 | + | .route("/user/approvals/{id}", post(approvals::answer)) |
|
| 114 | 118 | .route("/user/sky", get(user_sky)) |
|
| 115 | 119 | .route("/user/starter", get(user_starter)) |
|
| 116 | 120 | .route("/user/flow/runs", get(user_flow_runs)) |
|
| … | 3394 unchanged lines |
modified crates/hub/src/auth/mod.rs +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | //! session cookies, CSRF tokens, TOTP, personal access tokens, roles, and |
|
| 3 | 3 | //! the viewer extractor pages use. |
|
| 4 | 4 | ||
| 5 | + | pub mod phone; |
|
| 5 | 6 | pub mod roles; |
|
| 6 | 7 | pub mod totp; |
|
| 7 | 8 | pub mod viewer; |
|
| … | 143 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | pub mod accounts; |
|
| 6 | 6 | pub mod activity; |
|
| 7 | + | pub mod approvals; |
|
| 7 | 8 | pub mod attachments; |
|
| 8 | 9 | #[cfg(feature = "postgres")] |
|
| 9 | 10 | pub mod copy; |
|
| … | 166 unchanged lines |
modified crates/hub/src/db/sessions.rs +37 −11
| … | 18 unchanged lines |
||
| 19 | 19 | pub seen_ms: i64, |
|
| 20 | 20 | pub address: String, |
|
| 21 | 21 | pub agent: String, |
|
| 22 | + | /// When it last proved a second factor, or signed in (oxygenhub#356): |
|
| 23 | + | /// the sudo step asks again once this is older than its window. |
|
| 24 | + | pub second_factor_ms: i64, |
|
| 22 | 25 | } |
|
| 23 | 26 | ||
| 24 | 27 | impl Session { |
|
| … | 3 unchanged lines |
||
| 28 | 31 | } |
|
| 29 | 32 | } |
|
| 30 | 33 | ||
| 31 | - | const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent"; |
|
| 34 | + | const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent, second_factor_ms"; |
|
| 32 | 35 | ||
| 33 | 36 | pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> { |
|
| 34 | 37 | let now = now_ms(); |
|
| 35 | - | sqlx::query_scalar("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES ($1, $2, $3, $4, $5) RETURNING id") |
|
| 36 | - | .bind(account_id) |
|
| 37 | - | .bind(hash) |
|
| 38 | - | .bind(salt) |
|
| 39 | - | .bind(now) |
|
| 40 | - | .bind(now) |
|
| 41 | - | .fetch_one(pool) |
|
| 42 | - | .await |
|
| 38 | + | // Signing in is as fresh as proof gets (oxygenhub#356). |
|
| 39 | + | sqlx::query_scalar( |
|
| 40 | + | "INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms, second_factor_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", |
|
| 41 | + | ) |
|
| 42 | + | .bind(account_id) |
|
| 43 | + | .bind(hash) |
|
| 44 | + | .bind(salt) |
|
| 45 | + | .bind(now) |
|
| 46 | + | .bind(now) |
|
| 47 | + | .bind(now) |
|
| 48 | + | .fetch_one(pool) |
|
| 49 | + | .await |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | /// The session proved a second factor (or the password, for an account |
|
| 53 | + | /// without one) at `now`: the sudo step lets it through for a while. |
|
| 54 | + | pub async fn mark_second_factor(pool: &Pool, session_id: i64, now: i64) -> sqlx::Result<()> { |
|
| 55 | + | sqlx::query("UPDATE sessions SET second_factor_ms = $1 WHERE id = $2").bind(now).bind(session_id).execute(pool).await?; |
|
| 56 | + | Ok(()) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | pub async fn by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Session>> { |
|
| 60 | + | sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE id = $1")).bind(id).fetch_optional(pool).await |
|
| 43 | 61 | } |
|
| 44 | 62 | ||
| 45 | 63 | pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> { |
|
| … | 65 unchanged lines |
||
| 111 | 129 | #[test] |
|
| 112 | 130 | fn a_session_ends_on_a_new_salt_when_idle_or_when_old() { |
|
| 113 | 131 | let t0 = 1_000_000_000_000; |
|
| 114 | - | let s = |
|
| 115 | - | Session { id: 1, account_id: 1, salt: "a".into(), created_ms: t0, seen_ms: t0, address: String::new(), agent: String::new() }; |
|
| 132 | + | let s = Session { |
|
| 133 | + | id: 1, |
|
| 134 | + | account_id: 1, |
|
| 135 | + | salt: "a".into(), |
|
| 136 | + | created_ms: t0, |
|
| 137 | + | seen_ms: t0, |
|
| 138 | + | address: String::new(), |
|
| 139 | + | agent: String::new(), |
|
| 140 | + | second_factor_ms: t0, |
|
| 141 | + | }; |
|
| 116 | 142 | assert!(s.live("a", t0 + 1000)); |
|
| 117 | 143 | assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere"); |
|
| 118 | 144 | assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days"); |
|
| … | 5 unchanged lines |
modified crates/hub/src/web/mod.rs +7 −0
| … | 57 unchanged lines |
||
| 58 | 58 | pub(crate) mod settings; |
|
| 59 | 59 | mod signin; |
|
| 60 | 60 | mod statics; |
|
| 61 | + | mod sudo; |
|
| 61 | 62 | pub mod test_reports; |
|
| 62 | 63 | mod variables; |
|
| 63 | 64 | mod webhooks; |
|
| … | 40 unchanged lines |
||
| 104 | 105 | .route("/favicon.ico", get(statics::favicon)) |
|
| 105 | 106 | .route("/static/{*path}", get(statics::asset)) |
|
| 106 | 107 | .route("/login", get(session::login_form).post(session::login)) |
|
| 108 | + | .route("/login/approval", post(session::approval_state)) |
|
| 109 | + | .route("/sudo", get(sudo::show).post(sudo::confirm)) |
|
| 110 | + | .route("/sudo/phone", post(sudo::ask_phone)) |
|
| 111 | + | .route("/sudo/approval/{id}", get(sudo::approval_state)) |
|
| 107 | 112 | .route("/device", get(device::form).post(device::answer)) |
|
| 108 | 113 | .route(crate::headers::REPORT_PATH, post(crate::headers::csp_report).layer(DefaultBodyLimit::max(16 * 1024))) |
|
| 109 | 114 | .route("/logout", post(session::logout)) |
|
| … | 7 unchanged lines |
||
| 117 | 122 | .route("/settings/security/totp/disable", post(settings::totp_disable)) |
|
| 118 | 123 | .route("/settings/security/sessions", post(settings::sessions_reset)) |
|
| 119 | 124 | .route("/settings/security/sessions/{id}/end", post(settings::session_end)) |
|
| 125 | + | .route("/settings/security/phones/{id}/remove", post(settings::phone_remove)) |
|
| 120 | 126 | .route("/settings/keys", get(settings::keys)) |
|
| 121 | 127 | .route("/settings/keys/upload", post(settings::keys_upload)) |
|
| 122 | 128 | .route("/settings/keys/verify", post(settings::keys_verify)) |
|
| … | 269 unchanged lines |
||
| 392 | 398 | .route("/{owner}/{name}/milestones", get(issues::milestones).post(issues::save_milestone)) |
|
| 393 | 399 | .route("/{owner}/{name}/oxy/v1/{endpoint}", any(crate::protocol::dispatch).layer(DefaultBodyLimit::max(push_limit))) |
|
| 394 | 400 | .fallback(not_found) |
|
| 401 | + | .layer(axum::middleware::from_fn_with_state(app.clone(), sudo::gate)) |
|
| 395 | 402 | .layer(axum::middleware::from_fn_with_state(app.clone(), rejections_as_pages)) |
|
| 396 | 403 | .layer(axum::middleware::from_fn_with_state(app.clone(), same_origin)) |
|
| 397 | 404 | .layer(CompressionLayer::new()) |
|
| … | 570 unchanged lines |
modified crates/hub/src/web/session.rs +84 −1
| … | 25 unchanged lines |
||
| 26 | 26 | reset: bool, |
|
| 27 | 27 | /// Providers to sign in with (#106): (id, name). |
|
| 28 | 28 | providers: Vec<(&'static str, &'static str)>, |
|
| 29 | + | /// Waiting for the phone (oxygenhub#356): the approval and the number |
|
| 30 | + | /// to pick on it. |
|
| 31 | + | approval: Option<(String, i64)>, |
|
| 32 | + | /// The account has a phone that approves, so the code step offers it. |
|
| 33 | + | phones: bool, |
|
| 29 | 34 | } |
|
| 30 | 35 | ||
| 31 | 36 | #[derive(Deserialize)] |
|
| … | 25 unchanged lines |
||
| 57 | 62 | pending: String::new(), |
|
| 58 | 63 | reset, |
|
| 59 | 64 | providers, |
|
| 65 | + | approval: None, |
|
| 66 | + | phones: false, |
|
| 60 | 67 | }) |
|
| 61 | 68 | .into_response() |
|
| 62 | 69 | } |
|
| … | 10 unchanged lines |
||
| 73 | 80 | code: String, |
|
| 74 | 81 | #[serde(default)] |
|
| 75 | 82 | next: String, |
|
| 83 | + | /// The approval the phone gave this sign-in (oxygenhub#356). |
|
| 84 | + | #[serde(default)] |
|
| 85 | + | approval: String, |
|
| 86 | + | /// Asked for the code rather than the phone. |
|
| 87 | + | #[serde(default)] |
|
| 88 | + | use_code: String, |
|
| 76 | 89 | } |
|
| 77 | 90 | ||
| 78 | 91 | pub async fn login( |
|
| … | 16 unchanged lines |
||
| 95 | 108 | .as_ref() |
|
| 96 | 109 | .map(|a| auth::pending_login(&app.cookie_key, &a.username, &a.session_salt, now_ms + PENDING_LOGIN_MS)) |
|
| 97 | 110 | .unwrap_or_default(); |
|
| 111 | + | let phones = match &account { |
|
| 112 | + | Some(a) if a.totp_secret.is_some() => { |
|
| 113 | + | !db::approvals::authenticators(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_empty() |
|
| 114 | + | } |
|
| 115 | + | _ => false, |
|
| 116 | + | }; |
|
| 98 | 117 | let fail = |msg: &str, totp_for: Option<String>| { |
|
| 99 | 118 | Page(LoginPage { |
|
| 100 | 119 | shell: shell.clone(), |
|
| 101 | - | error: Some(msg.into()), |
|
| 120 | + | error: (!msg.is_empty()).then(|| msg.into()), |
|
| 102 | 121 | next: next.clone(), |
|
| 103 | 122 | totp_for, |
|
| 104 | 123 | pending: pending.clone(), |
|
| 105 | 124 | reset: false, |
|
| 106 | 125 | providers: vec![], |
|
| 126 | + | approval: None, |
|
| 127 | + | phones, |
|
| 107 | 128 | }) |
|
| 108 | 129 | .into_response() |
|
| 109 | 130 | }; |
|
| … | 31 unchanged lines |
||
| 141 | 162 | } |
|
| 142 | 163 | if let Some(secret) = &account.totp_secret { |
|
| 143 | 164 | if f.code.trim().is_empty() { |
|
| 165 | + | // The phone, when the account has one (oxygenhub#356): an |
|
| 166 | + | // approval it gave this sign-in, or a new one to ask for. |
|
| 167 | + | if !f.use_code.is_empty() { |
|
| 168 | + | return Ok(fail("", Some(username))); |
|
| 169 | + | } |
|
| 170 | + | if !f.approval.is_empty() { |
|
| 171 | + | if !db::approvals::take(&app.db, &f.approval, account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 172 | + | return Ok(fail("Your phone has not approved this sign-in. Try again, or use a code.", Some(username))); |
|
| 173 | + | } |
|
| 174 | + | app.throttle.lock().unwrap().cleared(&keys[0]); |
|
| 175 | + | accounts::audit(&app.db, Some(&account), "login", &username, "approved on a phone").await.ok(); |
|
| 176 | + | return Ok(signed_in(&app, &account, &next).await); |
|
| 177 | + | } |
|
| 178 | + | if phones { |
|
| 179 | + | let agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or(""); |
|
| 180 | + | let from = if addr.is_empty() { String::new() } else { format!(", {addr}") }; |
|
| 181 | + | let what = format!("Sign in from {}{from}", super::settings::browser_of(agent)); |
|
| 182 | + | let a = |
|
| 183 | + | crate::auth::phone::ask(&app, &account, "signin", &what, None).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 184 | + | return Ok(Page(LoginPage { |
|
| 185 | + | shell: shell.clone(), |
|
| 186 | + | error: None, |
|
| 187 | + | next: next.clone(), |
|
| 188 | + | totp_for: Some(username), |
|
| 189 | + | pending: pending.clone(), |
|
| 190 | + | reset: false, |
|
| 191 | + | providers: vec![], |
|
| 192 | + | approval: Some((a.id, a.number)), |
|
| 193 | + | phones, |
|
| 194 | + | }) |
|
| 195 | + | .into_response()); |
|
| 196 | + | } |
|
| 144 | 197 | return Ok(fail("Enter the code from your authenticator app.", Some(username))); |
|
| 145 | 198 | } |
|
| 146 | 199 | let now = (db::now_ms() / 1000) as u64; |
|
| … | 13 unchanged lines |
||
| 160 | 213 | app.throttle.lock().unwrap().cleared(&keys[0]); |
|
| 161 | 214 | accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok(); |
|
| 162 | 215 | Ok(signed_in(&app, &account, &next).await) |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | #[derive(Deserialize)] |
|
| 219 | + | pub struct ApprovalPoll { |
|
| 220 | + | username: String, |
|
| 221 | + | pending: String, |
|
| 222 | + | approval: String, |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | /// `POST /login/approval` (oxygenhub#356): what became of a sign-in's |
|
| 226 | + | /// approval, for the page waiting on the phone. Only the sign-in that |
|
| 227 | + | /// proved the password may ask. |
|
| 228 | + | pub async fn approval_state(State(app): State<Shared>, Form(f): Form<ApprovalPoll>) -> Response { |
|
| 229 | + | let now = db::now_ms(); |
|
| 230 | + | let state = async { |
|
| 231 | + | let account = accounts::account_by_name(&app.db, f.username.trim()).await.ok()??; |
|
| 232 | + | if !auth::pending_login_ok(&app.cookie_key, &account.username, &account.session_salt, &f.pending, now) { |
|
| 233 | + | return None; |
|
| 234 | + | } |
|
| 235 | + | let a = db::approvals::get(&app.db, &f.approval).await.ok()??; |
|
| 236 | + | if a.account_id != account.id || a.kind != "signin" { |
|
| 237 | + | return None; |
|
| 238 | + | } |
|
| 239 | + | Some(if a.state == "pending" && a.expires_ms <= now { "expired".to_string() } else { a.state }) |
|
| 240 | + | } |
|
| 241 | + | .await; |
|
| 242 | + | match state { |
|
| 243 | + | Some(s) => axum::Json(serde_json::json!({ "state": s })).into_response(), |
|
| 244 | + | None => (axum::http::StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "state": "unknown" }))).into_response(), |
|
| 245 | + | } |
|
| 163 | 246 | } |
|
| 164 | 247 | ||
| 165 | 248 | /// How long the code step of a sign-in may take after the password. |
|
| … | 163 unchanged lines |
modified crates/hub/src/web/settings.rs +45 −2
| … | 304 unchanged lines |
||
| 305 | 305 | connected: Vec<(&'static str, &'static str, Option<String>)>, |
|
| 306 | 306 | /// Where the account is signed in (oxygenhub#223). |
|
| 307 | 307 | sessions: Vec<SessionView>, |
|
| 308 | + | /// Phones that approve as a second factor (oxygenhub#356). |
|
| 309 | + | phones: Vec<crate::db::approvals::Authenticator>, |
|
| 308 | 310 | } |
|
| 309 | 311 | ||
| 310 | 312 | /// One signed-in browser, as Settings › Security lists it. |
|
| … | 73 unchanged lines |
||
| 384 | 386 | let me = require(&viewer, &shell)?; |
|
| 385 | 387 | let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 386 | 388 | 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 })) |
|
| 389 | + | let phones = crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 390 | + | Ok(Page(SecurityPage { |
|
| 391 | + | shell, |
|
| 392 | + | me: me.account, |
|
| 393 | + | notice: q.notice, |
|
| 394 | + | error: q.error, |
|
| 395 | + | enrol: None, |
|
| 396 | + | recovery: vec![], |
|
| 397 | + | connected, |
|
| 398 | + | sessions, |
|
| 399 | + | phones, |
|
| 400 | + | })) |
|
| 388 | 401 | } |
|
| 389 | 402 | ||
| 390 | 403 | /// Each sign-in provider this hub offers, with the login connected to the account. |
|
| … | 35 unchanged lines |
||
| 426 | 439 | recovery: vec![], |
|
| 427 | 440 | connected: vec![], |
|
| 428 | 441 | sessions: vec![], |
|
| 442 | + | phones: vec![], |
|
| 429 | 443 | })) |
|
| 430 | 444 | } |
|
| 431 | 445 | ||
| … | 15 unchanged lines |
||
| 447 | 461 | recovery: vec![], |
|
| 448 | 462 | connected: vec![], |
|
| 449 | 463 | sessions: vec![], |
|
| 464 | + | phones: vec![], |
|
| 450 | 465 | })); |
|
| 451 | 466 | } |
|
| 452 | 467 | let (codes, stored) = totp::new_recovery_codes(); |
|
| … | 9 unchanged lines |
||
| 462 | 477 | recovery: codes, |
|
| 463 | 478 | connected: vec![], |
|
| 464 | 479 | sessions: vec![], |
|
| 480 | + | phones: vec![], |
|
| 465 | 481 | })) |
|
| 466 | 482 | } |
|
| 467 | 483 | ||
| … | 11 unchanged lines |
||
| 479 | 495 | recovery: vec![], |
|
| 480 | 496 | connected: vec![], |
|
| 481 | 497 | sessions: vec![], |
|
| 498 | + | phones: vec![], |
|
| 482 | 499 | })); |
|
| 483 | 500 | } |
|
| 484 | 501 | accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 485 | 502 | accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok(); |
|
| 503 | + | // A phone stands beside the app's codes, never alone (oxygenhub#356). |
|
| 504 | + | for p in crate::db::approvals::authenticators(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 505 | + | crate::db::approvals::remove_authenticator(&app.db, me.account.id, p.token_id) |
|
| 506 | + | .await |
|
| 507 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 508 | + | } |
|
| 486 | 509 | 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); |
|
| 487 | 510 | Ok(Page(SecurityPage { |
|
| 488 | 511 | shell, |
|
| 489 | 512 | me: fresh, |
|
| 490 | - | notice: Some("Two-factor authentication is off.".into()), |
|
| 513 | + | notice: Some("Two-factor authentication is off, and no phone approves for you now.".into()), |
|
| 491 | 514 | error: None, |
|
| 492 | 515 | enrol: None, |
|
| 493 | 516 | recovery: vec![], |
|
| 494 | 517 | connected: vec![], |
|
| 495 | 518 | sessions: vec![], |
|
| 519 | + | phones: vec![], |
|
| 496 | 520 | })) |
|
| 497 | 521 | } |
|
| 498 | 522 | ||
| … | 25 unchanged lines |
||
| 524 | 548 | } |
|
| 525 | 549 | let to = if id == me.session_id { "/" } else { "/settings/security?notice=That+browser+is+signed+out." }; |
|
| 526 | 550 | Ok(Redirect::to(to).into_response()) |
|
| 551 | + | } |
|
| 552 | + | ||
| 553 | + | /// `POST /settings/security/phones/{id}/remove` (oxygenhub#356): that |
|
| 554 | + | /// phone no longer approves for the account. |
|
| 555 | + | pub async fn phone_remove( |
|
| 556 | + | State(app): State<Shared>, |
|
| 557 | + | viewer: Viewer, |
|
| 558 | + | Path(id): Path<i64>, |
|
| 559 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 560 | + | ) -> Result<Response, Error> { |
|
| 561 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 562 | + | let me = require(&viewer, &shell)?; |
|
| 563 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 564 | + | let removed = |
|
| 565 | + | crate::db::approvals::remove_authenticator(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 566 | + | if removed { |
|
| 567 | + | accounts::audit(&app.db, Some(&me.account), "2fa.phone_removed", &me.account.username, &id.to_string()).await.ok(); |
|
| 568 | + | } |
|
| 569 | + | Ok(Redirect::to("/settings/security?notice=That+phone+no+longer+approves+for+you.#phones").into_response()) |
|
| 527 | 570 | } |
|
| 528 | 571 | ||
| 529 | 572 | // ------------------------------------------------------------------- keys |
|
| … | 291 unchanged lines |
modified crates/hub/tests/accounts.rs +200 −0
| … | 1797 unchanged lines |
||
| 1798 | 1798 | let anon = get(app, "/explore", None).await.body; |
|
| 1799 | 1799 | assert!(!anon.contains("data-fragment=\"bell\""), "nothing to hear signed out"); |
|
| 1800 | 1800 | } |
|
| 1801 | + | ||
| 1802 | + | /// A bearer call to the API: status and JSON. |
|
| 1803 | + | async fn bearer(app: &Shared, method: &str, path: &str, token: &str, json: Option<&str>) -> (StatusCode, serde_json::Value) { |
|
| 1804 | + | let mut req = Request::builder().method(method).uri(path).header(header::AUTHORIZATION, format!("Bearer {token}")); |
|
| 1805 | + | let body = match json { |
|
| 1806 | + | Some(j) => { |
|
| 1807 | + | req = req.header(header::CONTENT_TYPE, "application/json"); |
|
| 1808 | + | Body::from(j.to_string()) |
|
| 1809 | + | } |
|
| 1810 | + | None => Body::empty(), |
|
| 1811 | + | }; |
|
| 1812 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 1813 | + | let status = res.status(); |
|
| 1814 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 1815 | + | (status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)) |
|
| 1816 | + | } |
|
| 1817 | + | ||
| 1818 | + | /// A value from a rendered form: `name="NAME" value="..."`. |
|
| 1819 | + | fn field(page: &str, name: &str) -> String { |
|
| 1820 | + | page.split(&format!("name=\"{name}\" value=\"")) |
|
| 1821 | + | .nth(1) |
|
| 1822 | + | .unwrap_or_else(|| panic!("no {name} in {page}")) |
|
| 1823 | + | .split('"') |
|
| 1824 | + | .next() |
|
| 1825 | + | .unwrap() |
|
| 1826 | + | .to_string() |
|
| 1827 | + | } |
|
| 1828 | + | ||
| 1829 | + | /// oxygenhub#356: a phone with a Keystore key approves sign-ins by the |
|
| 1830 | + | /// number the browser shows, a wrong number denies, the sudo step asks a |
|
| 1831 | + | /// stale session again before sensitive changes, and turning the app codes |
|
| 1832 | + | /// off takes the phone with them. |
|
| 1833 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1834 | + | async fn a_phone_approves_sign_ins_and_the_sudo_step() { |
|
| 1835 | + | use ring::rand::SystemRandom; |
|
| 1836 | + | use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING}; |
|
| 1837 | + | let h = hub().await; |
|
| 1838 | + | let app = &h.app; |
|
| 1839 | + | let andy = register(app, "andy", "").await; |
|
| 1840 | + | let now_code = |secret: &str| oxygenhub::auth::totp::current(secret, (db::now_ms() / 1000) as u64).unwrap(); |
|
| 1841 | + | ||
| 1842 | + | // A token for the phone, as its sign-in would give it. |
|
| 1843 | + | let page = get(app, "/settings/tokens", Some(&andy)).await; |
|
| 1844 | + | let r = post( |
|
| 1845 | + | app, |
|
| 1846 | + | "/settings/tokens", |
|
| 1847 | + | Some(&andy), |
|
| 1848 | + | &format!("_csrf={}&name=Pixel&scopes=user%3Aread&scopes=repo%3Aread", csrf_of(&page.body)), |
|
| 1849 | + | ) |
|
| 1850 | + | .await; |
|
| 1851 | + | let token = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).expect(&r.body); |
|
| 1852 | + | ||
| 1853 | + | // The phone's key, encoded as Android's Keystore gives it. |
|
| 1854 | + | let rng = SystemRandom::new(); |
|
| 1855 | + | let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap(); |
|
| 1856 | + | let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap(); |
|
| 1857 | + | let spki = format!("3059301306072a8648ce3d020106082a8648ce3d030107034200{}", hex::encode(key.public_key().as_ref())); |
|
| 1858 | + | let add = |code: &str| format!(r#"{{"public_key":"{spki}","code":"{code}"}}"#); |
|
| 1859 | + | ||
| 1860 | + | // Without the app's codes there is nothing to fall back on: refused. |
|
| 1861 | + | let (st, _) = bearer(app, "POST", "/api/v1/user/authenticators", &token, Some(&add("123456"))).await; |
|
| 1862 | + | assert_eq!(st, StatusCode::CONFLICT); |
|
| 1863 | + | let sec = get(app, "/settings/security", Some(&andy)).await; |
|
| 1864 | + | let csrf = csrf_of(&sec.body); |
|
| 1865 | + | let r = post(app, "/settings/security/totp/begin", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 1866 | + | let secret = field(&r.body, "secret"); |
|
| 1867 | + | let r = |
|
| 1868 | + | post(app, "/settings/security/totp/enable", Some(&andy), &format!("_csrf={csrf}&secret={secret}&code={}", now_code(&secret))).await; |
|
| 1869 | + | assert!(r.body.contains("Recovery codes"), "{}", r.body); |
|
| 1870 | + | let (st, _) = bearer(app, "POST", "/api/v1/user/authenticators", &token, Some(&add("000000"))).await; |
|
| 1871 | + | assert_eq!(st, StatusCode::FORBIDDEN, "a token alone cannot add a phone"); |
|
| 1872 | + | let (st, v) = bearer(app, "POST", "/api/v1/user/authenticators", &token, Some(&add(&now_code(&secret)))).await; |
|
| 1873 | + | assert_eq!(st, StatusCode::CREATED, "{v}"); |
|
| 1874 | + | assert_eq!((v["name"].as_str(), v["this"].as_bool()), (Some("Pixel"), Some(true))); |
|
| 1875 | + | assert!(get(app, "/settings/security", Some(&andy)).await.body.contains("<strong>Pixel</strong>"), "listed under Phones"); |
|
| 1876 | + | ||
| 1877 | + | // Signing in asks the phone, showing one of the three numbers it offers. |
|
| 1878 | + | let sign_in = || post(app, "/login", None, "username=andy&password=correct-horse-battery"); |
|
| 1879 | + | let r = sign_in().await; |
|
| 1880 | + | assert!(r.body.contains("Open OxygenHub on your phone"), "{}", r.body); |
|
| 1881 | + | let (id, pending) = (field(&r.body, "approval"), field(&r.body, "pending")); |
|
| 1882 | + | let shown: i64 = |
|
| 1883 | + | r.body.split("class=\"approve-number\"").nth(1).unwrap().split('>').nth(1).unwrap().split('<').next().unwrap().parse().unwrap(); |
|
| 1884 | + | let poll = |id: String, pending: String| async move { |
|
| 1885 | + | let r = post(app, "/login/approval", None, &format!("username=andy&pending={pending}&approval={id}")).await; |
|
| 1886 | + | serde_json::from_str::<serde_json::Value>(&r.body).unwrap()["state"].as_str().unwrap().to_string() |
|
| 1887 | + | }; |
|
| 1888 | + | assert_eq!(poll(id.clone(), pending.clone()).await, "pending"); |
|
| 1889 | + | assert_eq!(poll(id.clone(), "1.forged".into()).await, "unknown", "only the sign-in that proved the password may ask"); |
|
| 1890 | + | let (_, v) = bearer(app, "GET", "/api/v1/user/approvals", &token, None).await; |
|
| 1891 | + | assert_eq!(v[0]["id"].as_str(), Some(id.as_str()), "{v}"); |
|
| 1892 | + | assert_eq!(v[0]["kind"], "signin"); |
|
| 1893 | + | assert!(v[0]["what"].as_str().unwrap().starts_with("Sign in from"), "{v}"); |
|
| 1894 | + | let choices: Vec<i64> = v[0]["choices"].as_array().unwrap().iter().map(|n| n.as_i64().unwrap()).collect(); |
|
| 1895 | + | assert!(choices.contains(&shown) && choices.len() == 3, "{choices:?} {shown}"); |
|
| 1896 | + | let sign = |id: &str, n: i64| hex::encode(key.sign(&rng, &oxygenhub::auth::phone::message(id, n, true)).unwrap().as_ref()); |
|
| 1897 | + | // Someone else's signature is not the phone's. |
|
| 1898 | + | let other = EcdsaKeyPair::from_pkcs8( |
|
| 1899 | + | &ECDSA_P256_SHA256_ASN1_SIGNING, |
|
| 1900 | + | EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap().as_ref(), |
|
| 1901 | + | &rng, |
|
| 1902 | + | ) |
|
| 1903 | + | .unwrap(); |
|
| 1904 | + | let forged = hex::encode(other.sign(&rng, &oxygenhub::auth::phone::message(&id, shown, true)).unwrap().as_ref()); |
|
| 1905 | + | let answer = |n: i64, sig: &str| format!(r#"{{"approve":true,"number":{n},"signature":"{sig}"}}"#); |
|
| 1906 | + | let (st, _) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(shown, &forged))).await; |
|
| 1907 | + | assert_eq!(st, StatusCode::FORBIDDEN); |
|
| 1908 | + | let (st, v) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(shown, &sign(&id, shown)))).await; |
|
| 1909 | + | assert_eq!((st, v["state"].as_str()), (StatusCode::OK, Some("approved")), "{v}"); |
|
| 1910 | + | assert_eq!(poll(id.clone(), pending.clone()).await, "approved"); |
|
| 1911 | + | let finish = |id: String, pending: String| async move { |
|
| 1912 | + | post(app, "/login", None, &format!("username=andy&pending={pending}&approval={id}&next=/")).await |
|
| 1913 | + | }; |
|
| 1914 | + | let r = finish(id.clone(), pending.clone()).await; |
|
| 1915 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 1916 | + | let phone_session = r.cookie.expect("signed in"); |
|
| 1917 | + | let r = finish(id.clone(), pending.clone()).await; |
|
| 1918 | + | assert!(r.cookie.is_none() && r.body.contains("has not approved"), "an approval signs in once: {}", r.body); |
|
| 1919 | + | ||
| 1920 | + | // A wrong number is a no, and a no signs nobody in. |
|
| 1921 | + | let r = sign_in().await; |
|
| 1922 | + | let (id, pending) = (field(&r.body, "approval"), field(&r.body, "pending")); |
|
| 1923 | + | let (_, v) = bearer(app, "GET", "/api/v1/user/approvals", &token, None).await; |
|
| 1924 | + | let shown2 = r |
|
| 1925 | + | .body |
|
| 1926 | + | .split("class=\"approve-number\"") |
|
| 1927 | + | .nth(1) |
|
| 1928 | + | .unwrap() |
|
| 1929 | + | .split('>') |
|
| 1930 | + | .nth(1) |
|
| 1931 | + | .unwrap() |
|
| 1932 | + | .split('<') |
|
| 1933 | + | .next() |
|
| 1934 | + | .unwrap() |
|
| 1935 | + | .parse::<i64>() |
|
| 1936 | + | .unwrap(); |
|
| 1937 | + | let wrong = v[0]["choices"].as_array().unwrap().iter().map(|n| n.as_i64().unwrap()).find(|n| *n != shown2).unwrap(); |
|
| 1938 | + | let (_, v) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(wrong, &sign(&id, wrong)))).await; |
|
| 1939 | + | assert_eq!(v["state"], "denied"); |
|
| 1940 | + | assert_eq!(poll(id.clone(), pending.clone()).await, "denied"); |
|
| 1941 | + | assert!(finish(id, pending).await.cookie.is_none()); |
|
| 1942 | + | // The code still works beside the phone. |
|
| 1943 | + | let r = post(app, "/login", None, "username=andy&password=correct-horse-battery&use_code=1").await; |
|
| 1944 | + | assert!(r.body.contains("six-digit code") && r.body.contains("Approve on my phone instead"), "{}", r.body); |
|
| 1945 | + | ||
| 1946 | + | // The sudo step: a fresh sign-in passes, a stale session is asked again. |
|
| 1947 | + | let tokens = |c: String| async move { |
|
| 1948 | + | let page = get(app, "/settings/tokens", Some(&c)).await; |
|
| 1949 | + | post(app, "/settings/tokens", Some(&c), &format!("_csrf={}&name=x&scopes=repo%3Aread", csrf_of(&page.body))).await |
|
| 1950 | + | }; |
|
| 1951 | + | assert_eq!(tokens(phone_session.clone()).await.status, StatusCode::OK, "just signed in"); |
|
| 1952 | + | sqlx::query("UPDATE sessions SET second_factor_ms = 0").execute(&app.db).await.unwrap(); |
|
| 1953 | + | let r = tokens(phone_session.clone()).await; |
|
| 1954 | + | assert_eq!(r.status, StatusCode::SEE_OTHER); |
|
| 1955 | + | assert!(r.location.as_deref().unwrap().starts_with("/sudo?next="), "{:?}", r.location); |
|
| 1956 | + | let sudo = get(app, "/sudo?next=/settings/tokens", Some(&phone_session)).await; |
|
| 1957 | + | assert!(sudo.body.contains("APPROVE ON MY PHONE"), "{}", sudo.body); |
|
| 1958 | + | let r = post(app, "/sudo/phone", Some(&phone_session), &format!("_csrf={}&next=/settings/tokens", csrf_of(&sudo.body))).await; |
|
| 1959 | + | let id = r.body.split("/sudo/approval/").nth(1).unwrap().split('"').next().unwrap().to_string(); |
|
| 1960 | + | let shown3: i64 = |
|
| 1961 | + | r.body.split("class=\"approve-number\"").nth(1).unwrap().split('>').nth(1).unwrap().split('<').next().unwrap().parse().unwrap(); |
|
| 1962 | + | let state = |c: String, id: String| async move { |
|
| 1963 | + | serde_json::from_str::<serde_json::Value>(&get(app, &format!("/sudo/approval/{id}"), Some(&c)).await.body).unwrap()["state"].clone() |
|
| 1964 | + | }; |
|
| 1965 | + | assert_eq!(state(phone_session.clone(), id.clone()).await, "pending"); |
|
| 1966 | + | assert_eq!(state(andy.clone(), id.clone()).await, "unknown", "another session cannot watch it"); |
|
| 1967 | + | let (_, v) = bearer(app, "GET", "/api/v1/user/approvals", &token, None).await; |
|
| 1968 | + | assert_eq!(v[0]["kind"], "stepup"); |
|
| 1969 | + | let (_, v) = bearer(app, "POST", &format!("/api/v1/user/approvals/{id}"), &token, Some(&answer(shown3, &sign(&id, shown3)))).await; |
|
| 1970 | + | assert_eq!(v["state"], "approved"); |
|
| 1971 | + | assert_eq!(state(phone_session.clone(), id).await, "approved"); |
|
| 1972 | + | assert_eq!(tokens(phone_session.clone()).await.status, StatusCode::OK, "fresh again"); |
|
| 1973 | + | // Or a code from the app. |
|
| 1974 | + | sqlx::query("UPDATE sessions SET second_factor_ms = 0").execute(&app.db).await.unwrap(); |
|
| 1975 | + | let sudo = get(app, "/sudo?next=/settings/tokens", Some(&phone_session)).await; |
|
| 1976 | + | let r = post(app, "/sudo", Some(&phone_session), &format!("_csrf={}&next=/settings/tokens&code=000000", csrf_of(&sudo.body))).await; |
|
| 1977 | + | assert!(r.body.contains("not right"), "{}", r.body); |
|
| 1978 | + | let r = post( |
|
| 1979 | + | app, |
|
| 1980 | + | "/sudo", |
|
| 1981 | + | Some(&phone_session), |
|
| 1982 | + | &format!("_csrf={}&next=/settings/tokens&code={}", csrf_of(&sudo.body), now_code(&secret)), |
|
| 1983 | + | ) |
|
| 1984 | + | .await; |
|
| 1985 | + | assert_eq!((r.status, r.location.as_deref()), (StatusCode::SEE_OTHER, Some("/settings/tokens"))); |
|
| 1986 | + | assert_eq!(tokens(phone_session.clone()).await.status, StatusCode::OK); |
|
| 1987 | + | ||
| 1988 | + | // Turning the app's codes off takes the phone with them. |
|
| 1989 | + | let sec = get(app, "/settings/security", Some(&phone_session)).await; |
|
| 1990 | + | let r = post( |
|
| 1991 | + | app, |
|
| 1992 | + | "/settings/security/totp/disable", |
|
| 1993 | + | Some(&phone_session), |
|
| 1994 | + | &format!("_csrf={}&password=correct-horse-battery", csrf_of(&sec.body)), |
|
| 1995 | + | ) |
|
| 1996 | + | .await; |
|
| 1997 | + | assert!(r.body.contains("no phone approves"), "{}", r.body); |
|
| 1998 | + | let (_, v) = bearer(app, "GET", "/api/v1/user/authenticators", &token, None).await; |
|
| 1999 | + | assert_eq!(v.as_array().map(Vec::len), Some(0), "{v}"); |
|
| 2000 | + | } |
modified docs/spec.md +2 −0
| … | 192 unchanged lines |
||
| 193 | 193 | ||
| 194 | 194 | 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. |
|
| 195 | 195 | ||
| 196 | + | **A phone as the second factor, and the sudo step (oxygenhub#356).** An account with an authenticator app may add phones: the OxygenHub app makes a P-256 key in the Android Keystore that signs only after a fingerprint, face or the device PIN, and registers its public half with `POST /api/v1/user/authenticators` (`{"public_key", "code"}`, the key's SubjectPublicKeyInfo in hex and the account's current app code, so a token alone cannot add a phone). A phone belongs to the sign-in (token) it made: signing that phone out, removing it under Settings › Security › Phones, or turning the app codes off removes it. Signing in with a phone, after the password the browser shows a two-digit number and the hub records an approval (two minutes) and sends it to the account's phones as an alert where alerts are on; the app also asks `GET /api/v1/user/approvals` when it opens. The phone offers three numbers; picking the one shown and confirming signs `oxygenhub approval v1\n{id}\n{number}\napprove` with its key (`POST /api/v1/user/approvals/{id}`), a wrong number denies, and the page, which asks `POST /login/approval` with its proof of the password every two seconds, signs in once, with the approval. "Use a code instead" stays. **The sudo step**: a change to the password, two-factor settings, phones, connected accounts, keys, tokens (making one, or an agent's), backups, an organisation's or repository's secrets, storage, webhooks, collaborators, push protection, deletion or transfer, and any change under `/admin`, from a session that has not proved a second factor for 15 minutes, goes first to `/sudo`, which asks the phone, an app or recovery code, or the password for an account with neither; signing in counts as proof. Audit records: `2fa.phone_added`, `2fa.approved`, `2fa.denied`, `2fa.phone_removed`, `sudo`, `sudo.failed`. Planned: the same step for API calls (landing into a protected bookmark, approving a guarded run) and organisations that require two-factor authentication. |
|
| 197 | + | ||
| 196 | 198 | #### 5.1.2 Sign in with GitHub or Google (done 2026-10-04) |
|
| 197 | 199 | ||
| 198 | 200 | An administrator sets up each provider under Administration › Sign in with: the OAuth client id, the client secret (sealed under `keys/secrets.key`, never shown again) and a switch; the page shows the callback URL to register with the provider (`/login/{provider}/callback`). The login page then offers the usable providers (oxygenhub#106). The flow is OAuth 2.0's authorization code: `/login/{provider}` sets a state cookie (a nonce and the purpose, MACed with the cookie key, ten minutes, path `/login`) and sends the person to the provider; the callback checks the state against the cookie, swaps the code for a token server to server, and asks who it is (GitHub: `/user` and the primary verified address from `/user/emails`; Google: the OpenID `userinfo`, the address only when `email_verified`). Then: a subject already linked signs in; else an account with that verified email is linked and signed in; else, when registration is open, or it is by invitation and an unused invitation came through the round trip (`/login/{provider}?invite=CODE`, carried in the signed state as the purpose `invite:CODE`, used up when the account is made; the registration page shows the provider buttons for a usable invitation, oxygenhub#214), an account is made from the provider's login name (made valid and free), with an unusable password and the email verified when the provider vouched for it; otherwise the person is told to register or sign in and connect. A signed-in person connects or disconnects a provider under Settings › Security; a subject can be linked to one account. Links live in `account_logins`. Audited as `admin.signin`, `signin.connected`, `signin.disconnected`, `login` and `register` with the provider. For tests, `[auth] signin_mock` names a base URL that stands in for every provider. |
|
| … | 698 unchanged lines |
modified templates/login.html +13 −1
| … | 12 unchanged lines |
||
| 13 | 13 | {% if let Some(u) = totp_for %} |
|
| 14 | 14 | <input type="hidden" name="username" value="{{ u }}"> |
|
| 15 | 15 | <input type="hidden" name="pending" value="{{ pending }}"> |
|
| 16 | + | {% if let Some((id, number)) = approval %} |
|
| 17 | + | {# Waiting on the phone (oxygenhub#356): the page asks what became of it and signs in once it is approved. #} |
|
| 18 | + | <input type="hidden" name="approval" value="{{ id }}"> |
|
| 19 | + | <div class="approve-wait" data-approval-poll="/login/approval" data-approval-id="{{ id }}" aria-live="polite"> |
|
| 20 | + | <p>Password accepted for <strong>{{ u }}</strong>. Open OxygenHub on your phone and pick</p> |
|
| 21 | + | <p class="approve-number" aria-label="the number {{ number }}">{{ number }}</p> |
|
| 22 | + | <p class="small mute" data-approval-status>Waiting for your phone…</p> |
|
| 23 | + | </div> |
|
| 24 | + | <button type="submit" name="use_code" value="1" class="small" formnovalidate>Use a code instead</button> |
|
| 25 | + | {% else %} |
|
| 16 | 26 | <p>Password accepted for <strong>{{ u }}</strong>. Enter the six-digit code from your authenticator app, or a recovery code.</p> |
|
| 17 | 27 | <label for="code">Code</label><input type="text" id="code" name="code" autocomplete="one-time-code" inputmode="numeric" autofocus> |
|
| 28 | + | {% endif %} |
|
| 18 | 29 | {% else %} |
|
| 19 | 30 | <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" autofocus required> |
|
| 20 | 31 | <label for="password">Password</label><input type="password" id="password" name="password" autocomplete="current-password" required> |
|
| 21 | 32 | {% endif %} |
|
| 22 | - | <button type="submit">SIGN IN</button> |
|
| 33 | + | {% if approval.is_none() %}<button type="submit">SIGN IN</button>{% endif %} |
|
| 34 | + | {% if totp_for.is_some() && approval.is_none() && phones %}<button type="submit" name="phone" value="1" class="small" formnovalidate>Approve on my phone instead</button>{% endif %} |
|
| 23 | 35 | </form> |
|
| 24 | 36 | {% if !providers.is_empty() && totp_for.is_none() %}<div class="signin-or" aria-hidden="true"><span>or</span></div> |
|
| 25 | 37 | {% let verb = "Sign in" %}{% let suffix = "" %}{% include "signin_buttons.html" %}{% endif %} |
|
| … | 3 unchanged lines |
modified templates/settings_security.html +14 −0
| … | 33 unchanged lines |
||
| 34 | 34 | </form> |
|
| 35 | 35 | {% endif %} |
|
| 36 | 36 | </section> |
|
| 37 | + | {% if me.totp_enabled() %}<section class="card-box" id="phones"> |
|
| 38 | + | <div class="card-head"><h2>Phones</h2><span class="count-chip">{{ phones.len() }}</span><span class="small mute more">approve with a fingerprint instead of typing a code</span></div> |
|
| 39 | + | {% if phones.is_empty() %}<p class="empty small">No phone approves for you yet. In the OxygenHub app, Account › <strong>Use this phone as a second factor</strong>.</p>{% else %} |
|
| 40 | + | <ul class="rows token-rows">{% for p in phones %} |
|
| 41 | + | <li> |
|
| 42 | + | <div class="token-main"> |
|
| 43 | + | <div class="token-name"><strong>{{ p.name }}</strong></div> |
|
| 44 | + | <div class="small mute">added <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time> · {% if p.last_used_ms > 0 %}last approved <time title="{{ p.last_used_ms|when_i }}">{{ p.last_used_ms|ago_i }}</time>{% else %}never used{% endif %}</div> |
|
| 45 | + | </div> |
|
| 46 | + | <div class="token-actions"><form class="inline" method="post" action="/settings/security/phones/{{ p.token_id }}/remove"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="small">Remove</button></form></div> |
|
| 47 | + | </li>{% endfor %} |
|
| 48 | + | </ul>{% endif %} |
|
| 49 | + | <p class="small mute pad sec-foot">Signing in, a phone shows three numbers; pick the one this page shows and confirm with your fingerprint, face or PIN. Your authenticator app's codes still work when the phone is not to hand.</p> |
|
| 50 | + | </section>{% endif %} |
|
| 37 | 51 | {% if !connected.is_empty() %}<section class="card-box" id="connected"> |
|
| 38 | 52 | <div class="card-head"><h2>Connected accounts</h2><span class="small mute more">sign in with these instead of a password</span></div> |
|
| 39 | 53 | <ul class="rows token-rows">{% for (id, name, login) in connected %} |
|
| … | 22 unchanged lines |
modified templates/style.css +6 −0
| … | 1330 unchanged lines |
||
| 1331 | 1331 | .sec-row input { flex: 0 1 320px; min-width: 0; } |
|
| 1332 | 1332 | .sec-split { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } |
|
| 1333 | 1333 | .sec-split p { margin: 0; } |
|
| 1334 | + | /* Waiting for a phone's approval (oxygenhub#356): the number to pick, big enough to read across a desk. */ |
|
| 1335 | + | .approve-wait { text-align: center; } |
|
| 1336 | + | .approve-wait p { margin: 6px 0; } |
|
| 1337 | + | .approve-number { font: 700 56px/1.1 var(--mono, ui-monospace, monospace); color: var(--acc); letter-spacing: 4px; margin: 8px 0 !important; } |
|
| 1338 | + | .approve-wait[data-state="approved"] .approve-number { color: var(--ok); } |
|
| 1339 | + | .approve-wait[data-state="denied"] .approve-number, .approve-wait[data-state="expired"] .approve-number { color: var(--bad); text-decoration: line-through; } |
|
| 1334 | 1340 | .card-box > .card-head form.more { margin-left: auto; } |
|
| 1335 | 1341 | .card-box > .sec-foot { margin: 0; border-top: 1px solid var(--line); } |
|
| 1336 | 1342 | .token-form { display: flex; flex-direction: column; gap: 6px; max-width: 720px; } |
|
| … | 176 unchanged lines |