diff --git a/PRIVACY.md b/PRIVACY.md index 95fd307..e4587a5 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # OpenTubeX Sync Server Privacy Policy -Last updated: August 26, 2026 +Last updated: September 1, 2026 This policy applies to the public OpenTubeX sync server at [sync.d3sox.me](https://sync.d3sox.me). Other operators running this @@ -18,9 +18,19 @@ does not control data processed by independently hosted instances. **Account data.** The server stores a unique account ID, a deterministic HMAC-derived value of your account name, and a salted Argon2 password hash. It -does not store your account name or password in plaintext. It issues a signed -authentication token after login but does not store that token in the -database. +does not store your account name or password in plaintext. Each current login +has an account session with a random session ID and device ID, an encrypted device +information record, creation and last-active times, an expiry time, and an +internal session-generation number. Pairing sessions additionally retain a +provisional-state flag until pairing succeeds. The +encrypted record contains the device name, operating system, system release, +and architecture. Your device encrypts it with your privacy key, so the server +cannot read those details. The server signs an authentication token that names +the stored session but does not store the full token in the database. Last-active +times are updated at most once every five minutes. For a still-valid token made +before account sessions were introduced, the server derives stable session and +device IDs from a SHA-256 digest of that token when it is first used. It does not +store the token itself. **Encrypted sync.** The public server supports encrypted sync, and OpenTubeX always uses it when the server supports it. Sync data is encrypted on your @@ -31,11 +41,13 @@ account activity, request timing, collection names, and approximate data size. The server cannot recover a lost privacy passphrase. **Device pairing.** Secure device pairing temporarily stores a one-time session -ID, SHA-256 recipient-token hash, recipient public key, pairing-scoped device -identifiers, the receiving device's user-chosen display name, expiry time, and +ID, SHA-256 recipient-token hash, recipient public key, device identifiers, the +receiving device's user-chosen display name, expiry time, and an encrypted pairing payload. It adds the account ID when an authenticated -device claims the session. Sessions expire after two minutes and are deleted -when they are consumed or cancelled. Poll, consume, and cancel requests send +device claims the session. Claiming creates a provisional account session; it +becomes active only when the pairing payload is consumed and is removed if the +pairing expires or is cancelled. Pairing sessions expire after two minutes and +are deleted when they are consumed or cancelled. Poll, consume, and cancel requests send the raw recipient token in a request header; the server stores only its hash. The server never receives the QR-only pairing secret, recipient private key, privacy key, privacy passphrase, or login password. It creates a fresh @@ -100,6 +112,14 @@ Account and sync data remain in the active database until you delete individual items or your account. Account deletion removes the account and its linked data from the active database. Shared public YouTube metadata may remain. +Account sessions remain until their one-year authentication token expires. +Revocation prevents authentication immediately but retains the session as a +tombstone until expiry so a legacy token cannot recreate it. A background task +deletes expired records, normally within one hour. Changing an account password +rotates the requesting device's token, advances the account's session generation, +and revokes every previous session, including concurrent logins and legacy tokens +that the server has not seen yet. + - Request logs are retained for up to seven days. - A database backup is created daily, with the seven most recent backups retained. Temporary migration or restore safety copies are retained for up diff --git a/README.md b/README.md index 1a615c1..78e820f 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,48 @@ For example: - Header: `Authorization: abcdefghijklmnopqrtuvwxyz` - Cookie: `Authorization=abcdefghijklmnopqrtuvwxyz` +### Account sessions + +Capability `account_sessions: 1` means authentication tokens are backed by +stored account sessions. Registration, password login, and OIDC login create an +active session. Secure pairing creates a provisional session that becomes active +only when the receiving device consumes the approved pairing payload. A token +contains that session's ID in its `jti` +claim, and authenticated requests fail after the session is revoked or expires. +Tokens issued before this capability was added do not contain `jti`. For +accounts present during the migration, the server accepts a still-valid legacy +token and creates its session on first use. New accounts require session-bound +tokens. The server +derives stable session and device IDs from a SHA-256 digest of the token and +does not store the token itself. Revoking that session leaves a tombstone until +the token expires, so the same token cannot recreate the session. + +Current clients send a random 16-byte base64url device ID when they authenticate. +The field is optional so older clients can continue to register and sign in; the +server assigns their device ID. Clients +encrypt the user-visible device name, operating system, system release, and +architecture with the enhanced-privacy key, then update the session with the +ciphertext. Clients can replace that ciphertext to rename any active device. +The server retains creation, last-active, and expiry times. It writes +last-active changes at most once every five minutes. + +The endpoints are: + +- authenticated `GET /v1/account/sessions` to list active sessions and whether the account supports password login +- authenticated `PATCH /v1/account/sessions/{id}` to store encrypted device information; `current` may be used as the ID for the requesting session +- authenticated `DELETE /v1/account/sessions/{id}` to revoke one session +- authenticated `PUT /v1/account/password` to change a password, revoke every existing session, and return a replacement JWT for the requesting device + +Expired and revoked sessions never authenticate. A background task removes them +after expiry, normally within one hour. Password changes verify the current +password and update its Argon2 hash in the same transaction that rotates the +requesting session, advances the account's session generation, and revokes the +rest. The generation check also rejects a concurrent login that verified the old +password but had not created its session yet. Password changes reject every legacy token +that has not yet created a session. This lets operators deploy the feature +without signing out old clients, while password changes still invalidate all +other access. + ### Enhanced privacy sync `GET /health` returns the server's capabilities alongside its health status. @@ -203,13 +245,14 @@ collections for one account cannot exceed 128 MiB. Capability `key_pairing: 1` advertises passwordless device pairing for enhanced-privacy sync. A receiving device anonymously creates a pending session, then shows a QR or text code. An already authenticated device claims -the session for its account and approves it. During the claim, the server mints -a fresh JWT for the receiving device. The approving device encrypts that JWT, +the session for its account and approves it. During the claim, the server creates +a provisional account session and mints a JWT for the receiving device. The approving +device encrypts that JWT, the account name, privacy key, privacy salt, and a six-digit verification code before uploading one opaque relay payload. The server stores the session ID, SHA-256 recipient-token hash, recipient public -key, pairing-scoped device IDs, receiving-device display name, expiry, account +key, device IDs, receiving-device display name, expiry, account ID after claim, and approved ciphertext. It never receives the QR-only secret, recipient private key, privacy key, or privacy passphrase. Poll, consume, and cancel requests send the raw recipient token in a request header; the server @@ -223,13 +266,14 @@ sessions globally and five claimed sessions per account. Authenticated pairing requests are limited to 120 per account per minute, while anonymous creation uses the server's address-based request limiter. Claim and approval accept an identical retry after success. Consumption atomically returns and deletes the -ciphertext, and cancellation deletes the session. +ciphertext, activates the provisional account session, and cancellation or +expiry deletes it. The endpoints are: - anonymous `POST /v1/pairing` to create a session with a recipient-token hash - recipient-token `GET /v1/pairing/{id}` to inspect its metadata and state -- authenticated `POST /v1/pairing/{id}/claim` to bind it to an account and mint a fresh JWT +- authenticated `POST /v1/pairing/{id}/claim` to bind it to an account, create its provisional session, and mint a JWT - authenticated `PUT /v1/pairing/{id}` to approve it with an opaque ciphertext - recipient-token `POST /v1/pairing/{id}/consume` to atomically consume it - recipient-token `DELETE /v1/pairing/{id}` to cancel it diff --git a/migrations/postgres/2026-09-01-000000-0000_account_sessions/down.sql b/migrations/postgres/2026-09-01-000000-0000_account_sessions/down.sql new file mode 100644 index 0000000..29e1791 --- /dev/null +++ b/migrations/postgres/2026-09-01-000000-0000_account_sessions/down.sql @@ -0,0 +1,3 @@ +DROP TABLE account_session; +ALTER TABLE account DROP COLUMN legacy_tokens_enabled; +ALTER TABLE account DROP COLUMN session_generation; diff --git a/migrations/postgres/2026-09-01-000000-0000_account_sessions/up.sql b/migrations/postgres/2026-09-01-000000-0000_account_sessions/up.sql new file mode 100644 index 0000000..ae9f476 --- /dev/null +++ b/migrations/postgres/2026-09-01-000000-0000_account_sessions/up.sql @@ -0,0 +1,20 @@ +ALTER TABLE account ADD COLUMN legacy_tokens_enabled BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE account ADD COLUMN session_generation BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE account_session( + id VARCHAR PRIMARY KEY NOT NULL, + account_id VARCHAR NOT NULL, + device_id VARCHAR NOT NULL, + encrypted_device_info TEXT, + created_at BIGINT NOT NULL, + last_active_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + revoked_at BIGINT, + legacy BOOLEAN NOT NULL DEFAULT FALSE, + generation BIGINT NOT NULL, + pending_pairing BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT FK__account_session__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE +); + +CREATE INDEX account_session_account_expires_idx + ON account_session(account_id, expires_at); diff --git a/migrations/sqlite/2026-09-01-000000-0000_account_sessions/down.sql b/migrations/sqlite/2026-09-01-000000-0000_account_sessions/down.sql new file mode 100644 index 0000000..29e1791 --- /dev/null +++ b/migrations/sqlite/2026-09-01-000000-0000_account_sessions/down.sql @@ -0,0 +1,3 @@ +DROP TABLE account_session; +ALTER TABLE account DROP COLUMN legacy_tokens_enabled; +ALTER TABLE account DROP COLUMN session_generation; diff --git a/migrations/sqlite/2026-09-01-000000-0000_account_sessions/up.sql b/migrations/sqlite/2026-09-01-000000-0000_account_sessions/up.sql new file mode 100644 index 0000000..ae9f476 --- /dev/null +++ b/migrations/sqlite/2026-09-01-000000-0000_account_sessions/up.sql @@ -0,0 +1,20 @@ +ALTER TABLE account ADD COLUMN legacy_tokens_enabled BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE account ADD COLUMN session_generation BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE account_session( + id VARCHAR PRIMARY KEY NOT NULL, + account_id VARCHAR NOT NULL, + device_id VARCHAR NOT NULL, + encrypted_device_info TEXT, + created_at BIGINT NOT NULL, + last_active_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + revoked_at BIGINT, + legacy BOOLEAN NOT NULL DEFAULT FALSE, + generation BIGINT NOT NULL, + pending_pairing BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT FK__account_session__account FOREIGN KEY(account_id) REFERENCES account(id) ON DELETE CASCADE +); + +CREATE INDEX account_session_account_expires_idx + ON account_session(account_id, expires_at); diff --git a/src/auth.rs b/src/auth.rs index 8813309..9eff2e8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -20,26 +20,30 @@ pub fn bytes_to_hex_string(bytes: &[u8]) -> String { } /// How long a freshly minted token stays valid. Should be enough in most cases. -const TOKEN_TTL: Duration = Duration::from_hours(365 * 24); +pub const TOKEN_TTL: Duration = Duration::from_hours(365 * 24); /// Allowance for clock skew between minting and verifying. const EXPIRY_LEEWAY: Duration = Duration::from_secs(5 * 60); -fn unix_now() -> u64 { +pub fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } -pub fn generate_jwt(account: &Account, secret_key: &[u8]) -> jsonwebtoken::errors::Result { +pub fn generate_jwt( + account: &Account, + session_id: &str, + expires_at: u64, + secret_key: &[u8], +) -> jsonwebtoken::errors::Result { let key = EncodingKey::from_secret(secret_key); - // `exp` is defined in seconds since the epoch, not milliseconds. - let expiration_date = unix_now().saturating_add(TOKEN_TTL.as_secs()); let claims = JwtClaims { sub: account.id.clone(), - exp: expiration_date as usize, + jti: Some(session_id.to_owned()), + exp: expires_at as usize, }; encode(&Header::default(), &claims, &key) } @@ -56,8 +60,7 @@ fn expiry_is_plausible(exp: u64, now: u64) -> bool { .saturating_add(EXPIRY_LEEWAY.as_secs()) } -/// Returns the User ID on success. -pub fn verify_jwt(encoded_jwt: &str, secret_key: &[u8]) -> jsonwebtoken::errors::Result { +pub fn verify_jwt(encoded_jwt: &str, secret_key: &[u8]) -> jsonwebtoken::errors::Result { let key = DecodingKey::from_secret(secret_key); let claims: JwtClaims = decode(encoded_jwt.as_bytes(), &key, &Validation::default())?.claims; @@ -65,7 +68,7 @@ pub fn verify_jwt(encoded_jwt: &str, secret_key: &[u8]) -> jsonwebtoken::errors: return Err(jsonwebtoken::errors::ErrorKind::InvalidToken.into()); } - Ok(claims.sub) + Ok(claims) } fn argon2_instance<'a>() -> Argon2<'a> { @@ -101,10 +104,20 @@ pub fn hash_accountname(accountname: &str, secret_key: &[u8]) -> String { #[cfg(test)] mod tests { - use super::{EXPIRY_LEEWAY, TOKEN_TTL, expiry_is_plausible}; + use jsonwebtoken::{EncodingKey, Header, encode}; + use serde::Serialize; + + use super::{EXPIRY_LEEWAY, TOKEN_TTL, expiry_is_plausible, generate_jwt, verify_jwt}; + use crate::models::Account; const NOW: u64 = 1_800_000_000; + #[derive(Serialize)] + struct LegacyClaims<'a> { + sub: &'a str, + exp: usize, + } + #[test] fn normal_expiries_are_accepted() { assert!(expiry_is_plausible(NOW + 60, NOW)); @@ -132,4 +145,43 @@ mod tests { )); assert!(!expiry_is_plausible(u64::MAX, NOW)); } + + #[test] + fn tokens_are_bound_to_their_account_session() { + let account = Account { + id: "account-id".to_owned(), + name_hash: "name-hash".to_owned(), + password_hash: Some("password-hash".to_owned()), + oidc_sub: None, + legacy_tokens_enabled: false, + session_generation: 0, + }; + let secret = b"a secret key long enough for this unit test"; + let expires_at = super::unix_now() + 60; + let jwt = generate_jwt(&account, "session-id", expires_at, secret).unwrap(); + let claims = verify_jwt(&jwt, secret).unwrap(); + + assert_eq!(claims.sub, account.id); + assert_eq!(claims.jti.as_deref(), Some("session-id")); + assert_eq!(claims.exp, expires_at as usize); + } + + #[test] + fn tokens_minted_before_session_management_still_verify() { + let secret = b"a secret key long enough for this unit test"; + let expires_at = super::unix_now() + 60; + let jwt = encode( + &Header::default(), + &LegacyClaims { + sub: "account-id", + exp: expires_at as usize, + }, + &EncodingKey::from_secret(secret), + ) + .unwrap(); + let claims = verify_jwt(&jwt, secret).unwrap(); + + assert_eq!(claims.sub, "account-id"); + assert_eq!(claims.jti, None); + } } diff --git a/src/database.rs b/src/database.rs index 8a35837..4c3302f 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod account_session; pub mod channel; pub mod channel_playback_speed; pub mod encrypted_sync; diff --git a/src/database/account_session.rs b/src/database/account_session.rs new file mode 100644 index 0000000..e5d33f2 --- /dev/null +++ b/src/database/account_session.rs @@ -0,0 +1,371 @@ +use diesel::prelude::*; +use diesel_async::{AsyncConnection, RunQueryDsl}; + +use crate::database::DbError; +use crate::models::AccountSession; +use crate::schema::account_session::dsl::{ + account_id, account_session, encrypted_device_info, expires_at, generation, id, last_active_at, + pending_pairing, revoked_at, +}; +use crate::{DbConnection, schema}; + +pub async fn create( + conn: &mut DbConnection, + session: &AccountSession, +) -> Result { + diesel::insert_into(account_session) + .values(session) + .returning(AccountSession::as_returning()) + .get_result(conn) + .await +} + +pub async fn get_or_create( + conn: &mut DbConnection, + session: &AccountSession, +) -> Result { + conn.transaction(|conn| { + Box::pin(async move { + diesel::insert_into(account_session) + .values(session) + .on_conflict(id) + .do_nothing() + .execute(conn) + .await?; + + account_session + .filter(id.eq(&session.id)) + .filter(account_id.eq(&session.account_id)) + .filter(generation.eq(session.generation)) + .filter(expires_at.gt(session.created_at)) + .filter(revoked_at.is_null()) + .select(AccountSession::as_select()) + .first(conn) + .await + }) + }) + .await +} + +pub async fn find_active( + conn: &mut DbConnection, + owner_id: &str, + session_id: &str, + current_generation: i64, + now: i64, +) -> Result, DbError> { + account_session + .filter(id.eq(session_id)) + .filter(account_id.eq(owner_id)) + .filter(generation.eq(current_generation)) + .filter(pending_pairing.eq(false)) + .filter(expires_at.gt(now)) + .filter(revoked_at.is_null()) + .select(AccountSession::as_select()) + .first(conn) + .await + .optional() +} + +pub async fn touch( + conn: &mut DbConnection, + session_id: &str, + current_generation: i64, + previous_activity: i64, + now: i64, +) -> Result { + if now.saturating_sub(previous_activity) < 5 * 60 * 1000 { + return Ok(false); + } + + diesel::update( + account_session + .filter(id.eq(session_id)) + .filter(generation.eq(current_generation)) + .filter(pending_pairing.eq(false)) + .filter(last_active_at.eq(previous_activity)) + .filter(expires_at.gt(now)) + .filter(revoked_at.is_null()), + ) + .set(last_active_at.eq(now)) + .execute(conn) + .await + .map(|updated| updated == 1) +} + +pub async fn list_active( + conn: &mut DbConnection, + owner_id: &str, + current_generation: i64, + now: i64, +) -> Result, DbError> { + diesel::delete( + account_session + .filter(account_id.eq(owner_id)) + .filter(expires_at.le(now)), + ) + .execute(conn) + .await?; + + account_session + .filter(account_id.eq(owner_id)) + .filter(generation.eq(current_generation)) + .filter(pending_pairing.eq(false)) + .filter(expires_at.gt(now)) + .filter(revoked_at.is_null()) + .order(last_active_at.desc()) + .select(AccountSession::as_select()) + .load(conn) + .await +} + +pub async fn update_encrypted_info( + conn: &mut DbConnection, + owner_id: &str, + session_id: &str, + current_generation: i64, + value: &str, + now: i64, +) -> Result { + diesel::update( + account_session + .filter(id.eq(session_id)) + .filter(account_id.eq(owner_id)) + .filter(generation.eq(current_generation)) + .filter(pending_pairing.eq(false)) + .filter(expires_at.gt(now)) + .filter(revoked_at.is_null()), + ) + .set(encrypted_device_info.eq(value)) + .execute(conn) + .await + .map(|updated| updated == 1) +} + +pub async fn revoke( + conn: &mut DbConnection, + owner_id: &str, + session_id: &str, + current_generation: i64, + now: i64, +) -> Result { + diesel::update( + account_session + .filter(id.eq(session_id)) + .filter(account_id.eq(owner_id)) + .filter(generation.eq(current_generation)) + .filter(pending_pairing.eq(false)) + .filter(revoked_at.is_null()), + ) + .set(revoked_at.eq(now)) + .execute(conn) + .await + .map(|updated| updated == 1) +} + +pub async fn change_password_and_revoke_others( + conn: &mut DbConnection, + owner_id: &str, + replacement: &AccountSession, + expected_password_hash: &str, + new_password_hash: &str, + now: i64, +) -> Result { + conn.transaction(|conn| { + Box::pin(async move { + let updated = diesel::update( + schema::account::table + .filter(schema::account::id.eq(owner_id)) + .filter(schema::account::password_hash.eq(Some(expected_password_hash))) + .filter( + schema::account::session_generation + .eq(replacement.generation.saturating_sub(1)), + ), + ) + .set(( + schema::account::password_hash.eq(Some(new_password_hash)), + schema::account::legacy_tokens_enabled.eq(false), + schema::account::session_generation.eq(replacement.generation), + )) + .execute(conn) + .await?; + if updated != 1 { + return Ok(false); + } + diesel::insert_into(account_session) + .values(replacement) + .execute(conn) + .await?; + diesel::update( + account_session + .filter(account_id.eq(owner_id)) + .filter(id.ne(&replacement.id)) + .filter(revoked_at.is_null()), + ) + .set(revoked_at.eq(now)) + .execute(conn) + .await?; + Ok(true) + }) + }) + .await +} + +pub async fn delete_expired(conn: &mut DbConnection, now: i64) -> Result { + diesel::delete(account_session.filter(expires_at.le(now))) + .execute(conn) + .await +} + +#[cfg(all(test, feature = "sqlite"))] +mod tests { + use diesel::connection::SimpleConnection; + use diesel_async::AsyncConnection; + use diesel_migrations::MigrationHarness; + + use super::{ + change_password_and_revoke_others, create, find_active, get_or_create, list_active, revoke, + touch, update_encrypted_info, + }; + use crate::database::account::find_account_by_id; + use crate::models::AccountSession; + use crate::{DbConnection, MIGRATIONS}; + + async fn connection() -> DbConnection { + let mut conn = DbConnection::establish(":memory:").await.unwrap(); + conn.spawn_blocking(|conn| { + conn.run_pending_migrations(MIGRATIONS).unwrap(); + conn.batch_execute( + "PRAGMA foreign_keys = ON; \ + INSERT INTO account (id, name_hash, password_hash, oidc_sub) \ + VALUES ('account-a', 'hash-a', 'old-password', NULL);", + )?; + Ok(()) + }) + .await + .unwrap(); + conn + } + + fn session(id: &str, last_active_at: i64, expires_at: i64) -> AccountSession { + AccountSession { + id: id.to_owned(), + account_id: "account-a".to_owned(), + device_id: format!("device-{id}"), + encrypted_device_info: None, + created_at: 100, + last_active_at, + expires_at, + revoked_at: None, + legacy: false, + generation: 0, + pending_pairing: false, + } + } + + #[actix_rt::test] + async fn active_sessions_can_be_listed_updated_touched_and_revoked() { + let mut conn = connection().await; + create(&mut conn, &session("current", 100, 500_000)) + .await + .unwrap(); + create(&mut conn, &session("expired", 100, 500)) + .await + .unwrap(); + + assert_eq!( + list_active(&mut conn, "account-a", 0, 500) + .await + .unwrap() + .len(), + 1 + ); + assert!( + update_encrypted_info(&mut conn, "account-a", "current", 0, "ciphertext", 500) + .await + .unwrap() + ); + assert!(!touch(&mut conn, "current", 0, 100, 500).await.unwrap()); + assert!(touch(&mut conn, "current", 0, 100, 400_000).await.unwrap()); + + let active = find_active(&mut conn, "account-a", "current", 0, 500) + .await + .unwrap() + .unwrap(); + assert_eq!(active.encrypted_device_info.as_deref(), Some("ciphertext")); + assert_eq!(active.last_active_at, 400_000); + assert!( + revoke(&mut conn, "account-a", "current", 0, 450_000) + .await + .unwrap() + ); + assert!( + find_active(&mut conn, "account-a", "current", 0, 500) + .await + .unwrap() + .is_none() + ); + assert!(matches!( + get_or_create(&mut conn, &session("current", 450_000, 500_000)).await, + Err(diesel::result::Error::NotFound) + )); + } + + #[actix_rt::test] + async fn password_change_rotates_the_requesting_session() { + let mut conn = connection().await; + create(&mut conn, &session("current", 100, 2_000)) + .await + .unwrap(); + create(&mut conn, &session("other", 100, 2_000)) + .await + .unwrap(); + let mut replacement = session("replacement", 500, 2_000); + replacement.generation = 1; + + assert!( + change_password_and_revoke_others( + &mut conn, + "account-a", + &replacement, + "old-password", + "new-password", + 500, + ) + .await + .unwrap() + ); + + let account = find_account_by_id(&mut conn, "account-a") + .await + .unwrap() + .unwrap(); + assert_eq!(account.password_hash.as_deref(), Some("new-password")); + assert!(!account.legacy_tokens_enabled); + assert_eq!(account.session_generation, 1); + let sessions = list_active(&mut conn, "account-a", 1, 500).await.unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].id, "replacement"); + + let late_login = session("late-login", 500, 2_000); + create(&mut conn, &late_login).await.unwrap(); + assert!( + find_active(&mut conn, "account-a", "late-login", 1, 500) + .await + .unwrap() + .is_none() + ); + assert!( + !change_password_and_revoke_others( + &mut conn, + "account-a", + &replacement, + "old-password", + "racing-password", + 500, + ) + .await + .unwrap() + ); + } +} diff --git a/src/database/pairing.rs b/src/database/pairing.rs index ca5990a..a9b0777 100644 --- a/src/database/pairing.rs +++ b/src/database/pairing.rs @@ -3,7 +3,7 @@ use diesel_async::{AsyncConnection, RunQueryDsl}; use crate::DbConnection; use crate::database::DbError; -use crate::models::PairingSession; +use crate::models::{AccountSession, PairingSession}; use crate::schema::pairing_session::dsl::{ account_id, approving_device_id, encrypted_payload, expires_at, id, pairing_session, recipient_device_id, recipient_device_name, recipient_public_key, recipient_token_hash, @@ -22,15 +22,38 @@ pub enum CreateResult { #[derive(Debug, PartialEq, Eq)] pub enum ClaimResult { - Claimed(Box), + Claimed { + pairing: Box, + account_session: Box, + }, Conflict, LimitExceeded, } pub async fn delete_expired(conn: &mut DbConnection, now: i64) -> Result { - diesel::delete(pairing_session.filter(expires_at.le(now))) - .execute(conn) - .await + conn.transaction(|conn| { + Box::pin(async move { + let expired_ids = pairing_session + .filter(expires_at.le(now)) + .select(id) + .load::(conn) + .await?; + if expired_ids.is_empty() { + return Ok(0); + } + diesel::delete( + crate::schema::account_session::table + .filter(crate::schema::account_session::id.eq_any(&expired_ids)) + .filter(crate::schema::account_session::pending_pairing.eq(true)), + ) + .execute(conn) + .await?; + diesel::delete(pairing_session.filter(id.eq_any(expired_ids))) + .execute(conn) + .await + }) + }) + .await } pub async fn create( @@ -68,6 +91,7 @@ pub async fn claim( conn: &mut DbConnection, owner_id: &str, request: &PairingSession, + candidate_session: &AccountSession, now: i64, ) -> Result { conn.transaction(|conn| { @@ -75,10 +99,17 @@ pub async fn claim( use crate::schema::account; // Serialize the per-account limit across workers and replicas. - diesel::update(account::table.filter(account::id.eq(owner_id))) - .set(account::id.eq(account::id)) - .execute(conn) - .await?; + let account_locked = diesel::update( + account::table + .filter(account::id.eq(owner_id)) + .filter(account::session_generation.eq(candidate_session.generation)), + ) + .set(account::id.eq(account::id)) + .execute(conn) + .await?; + if account_locked != 1 { + return Ok(ClaimResult::Conflict); + } delete_expired(conn, now).await?; let existing = pairing_session @@ -95,7 +126,13 @@ pub async fn claim( .await .optional()?; if let Some(session) = existing { - return Ok(ClaimResult::Claimed(Box::new(session))); + let account_session = + crate::database::account_session::get_or_create(conn, candidate_session) + .await?; + return Ok(ClaimResult::Claimed { + pairing: Box::new(session), + account_session: Box::new(account_session), + }); } let active = pairing_session @@ -124,9 +161,14 @@ pub async fn claim( .await .optional()?; - Ok(match claimed { - Some(session) => ClaimResult::Claimed(Box::new(session)), - None => ClaimResult::Conflict, + let Some(pairing) = claimed else { + return Ok(ClaimResult::Conflict); + }; + let account_session = + crate::database::account_session::get_or_create(conn, candidate_session).await?; + Ok(ClaimResult::Claimed { + pairing: Box::new(pairing), + account_session: Box::new(account_session), }) }) }) @@ -196,18 +238,38 @@ pub async fn consume( token_hash: &str, now: i64, ) -> Result, DbError> { - diesel::delete( - pairing_session - .filter(id.eq(session_id)) - .filter(version.eq(1)) - .filter(recipient_token_hash.eq(token_hash)) - .filter(expires_at.gt(now)) - .filter(encrypted_payload.is_not_null()), - ) - .returning(PairingSession::as_returning()) - .get_result(conn) + conn.transaction(|conn| { + Box::pin(async move { + let consumed = diesel::delete( + pairing_session + .filter(id.eq(session_id)) + .filter(version.eq(1)) + .filter(recipient_token_hash.eq(token_hash)) + .filter(expires_at.gt(now)) + .filter(encrypted_payload.is_not_null()), + ) + .returning(PairingSession::as_returning()) + .get_result(conn) + .await + .optional()?; + let Some(session) = consumed else { + return Ok(None); + }; + let activated = diesel::update( + crate::schema::account_session::table + .filter(crate::schema::account_session::id.eq(session_id)) + .filter(crate::schema::account_session::pending_pairing.eq(true)), + ) + .set(crate::schema::account_session::pending_pairing.eq(false)) + .execute(conn) + .await?; + if activated != 1 { + return Err(diesel::result::Error::NotFound); + } + Ok(Some(session)) + }) + }) .await - .optional() } pub async fn cancel( @@ -215,15 +277,30 @@ pub async fn cancel( session_id: &str, token_hash: &str, ) -> Result { - let deleted = diesel::delete( - pairing_session - .filter(id.eq(session_id)) - .filter(version.eq(1)) - .filter(recipient_token_hash.eq(token_hash)), - ) - .execute(conn) - .await?; - Ok(deleted == 1) + conn.transaction(|conn| { + Box::pin(async move { + let deleted = diesel::delete( + pairing_session + .filter(id.eq(session_id)) + .filter(version.eq(1)) + .filter(recipient_token_hash.eq(token_hash)), + ) + .execute(conn) + .await?; + if deleted != 1 { + return Ok(false); + } + diesel::delete( + crate::schema::account_session::table + .filter(crate::schema::account_session::id.eq(session_id)) + .filter(crate::schema::account_session::pending_pairing.eq(true)), + ) + .execute(conn) + .await?; + Ok(true) + }) + }) + .await } #[cfg(all(test, feature = "sqlite"))] @@ -235,7 +312,7 @@ mod tests { use super::{ ClaimResult, CreateResult, approve, cancel, claim, consume, create, delete_expired, get, }; - use crate::models::PairingSession; + use crate::models::{AccountSession, PairingSession}; use crate::{DbConnection, MIGRATIONS}; async fn connection() -> DbConnection { @@ -271,6 +348,22 @@ mod tests { } } + fn account_session(request: &PairingSession, owner_id: &str) -> AccountSession { + AccountSession { + id: request.id.clone(), + account_id: owner_id.to_owned(), + device_id: request.recipient_device_id.clone(), + encrypted_device_info: Some("ciphertext".to_owned()), + created_at: 100, + last_active_at: 100, + expires_at: 1_000_000, + revoked_at: None, + legacy: false, + generation: 0, + pending_pairing: true, + } + } + #[actix_rt::test] async fn sessions_require_the_recipient_token_and_are_single_use() { let mut conn = connection().await; @@ -297,13 +390,35 @@ mod tests { ); assert!(matches!( - claim(&mut conn, "account-a", &request, 100).await.unwrap(), - ClaimResult::Claimed(_) + claim( + &mut conn, + "account-a", + &request, + &account_session(&request, "account-a"), + 100, + ) + .await + .unwrap(), + ClaimResult::Claimed { .. } )); assert_eq!( - claim(&mut conn, "account-b", &request, 100).await.unwrap(), + claim( + &mut conn, + "account-b", + &request, + &account_session(&request, "account-b"), + 100, + ) + .await + .unwrap(), ClaimResult::Conflict ); + assert!( + crate::database::account_session::list_active(&mut conn, "account-a", 0, 100) + .await + .unwrap() + .is_empty() + ); assert!( !approve(&mut conn, "account-b", "session", "device", "payload", 100) .await @@ -343,6 +458,13 @@ mod tests { .unwrap() .unwrap(); assert_eq!(payload.encrypted_payload.as_deref(), Some("payload")); + assert_eq!( + crate::database::account_session::list_active(&mut conn, "account-a", 0, 100) + .await + .unwrap() + .len(), + 1 + ); assert!( consume(&mut conn, "session", "token-session", 100) .await @@ -362,7 +484,15 @@ mod tests { let mut changed = request.clone(); changed.recipient_device_name = "Changed".to_owned(); assert_eq!( - claim(&mut conn, "account-a", &changed, 100).await.unwrap(), + claim( + &mut conn, + "account-a", + &changed, + &account_session(&changed, "account-a"), + 100, + ) + .await + .unwrap(), ClaimResult::Conflict ); assert!( @@ -373,7 +503,15 @@ mod tests { ); assert_eq!(delete_expired(&mut conn, 200).await.unwrap(), 1); assert_eq!( - claim(&mut conn, "account-a", &request, 200).await.unwrap(), + claim( + &mut conn, + "account-a", + &request, + &account_session(&request, "account-a"), + 200, + ) + .await + .unwrap(), ClaimResult::Conflict ); assert!(!cancel(&mut conn, "session", "token-session").await.unwrap()); @@ -389,15 +527,31 @@ mod tests { CreateResult::Created ); assert!(matches!( - claim(&mut conn, "account-a", &request, 100).await.unwrap(), - ClaimResult::Claimed(_) + claim( + &mut conn, + "account-a", + &request, + &account_session(&request, "account-a"), + 100, + ) + .await + .unwrap(), + ClaimResult::Claimed { .. } )); } let retry = session("session-0", 1_000); assert!(matches!( - claim(&mut conn, "account-a", &retry, 100).await.unwrap(), - ClaimResult::Claimed(_) + claim( + &mut conn, + "account-a", + &retry, + &account_session(&retry, "account-a"), + 100, + ) + .await + .unwrap(), + ClaimResult::Claimed { .. } )); let excess = session("session-5", 1_000); @@ -406,12 +560,28 @@ mod tests { CreateResult::Created ); assert_eq!( - claim(&mut conn, "account-a", &excess, 100).await.unwrap(), + claim( + &mut conn, + "account-a", + &excess, + &account_session(&excess, "account-a"), + 100, + ) + .await + .unwrap(), ClaimResult::LimitExceeded ); assert!(matches!( - claim(&mut conn, "account-b", &excess, 100).await.unwrap(), - ClaimResult::Claimed(_) + claim( + &mut conn, + "account-b", + &excess, + &account_session(&excess, "account-b"), + 100, + ) + .await + .unwrap(), + ClaimResult::Claimed { .. } )); } @@ -420,7 +590,36 @@ mod tests { let mut conn = connection().await; let request = session("session", 1_000); create(&mut conn, &request, 100).await.unwrap(); + let provisional = account_session(&request, "account-a"); + assert!(matches!( + claim(&mut conn, "account-a", &request, &provisional, 100) + .await + .unwrap(), + ClaimResult::Claimed { .. } + )); assert!(!cancel(&mut conn, "session", "wrong-token").await.unwrap()); assert!(cancel(&mut conn, "session", "token-session").await.unwrap()); + crate::database::account_session::create(&mut conn, &provisional) + .await + .expect("cancelling must remove the provisional account session"); + } + + #[actix_rt::test] + async fn expiration_removes_the_provisional_account_session() { + let mut conn = connection().await; + let request = session("session", 200); + create(&mut conn, &request, 100).await.unwrap(); + let provisional = account_session(&request, "account-a"); + assert!(matches!( + claim(&mut conn, "account-a", &request, &provisional, 100) + .await + .unwrap(), + ClaimResult::Claimed { .. } + )); + + assert_eq!(delete_expired(&mut conn, 200).await.unwrap(), 1); + crate::database::account_session::create(&mut conn, &provisional) + .await + .expect("expiration must remove the provisional account session"); } } diff --git a/src/dto.rs b/src/dto.rs index c0602d1..9efec2d 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -9,12 +9,14 @@ use crate::models::{ pub struct RegisterUser { pub name: String, pub password: String, + pub device_id: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] pub struct LoginUser { pub name: String, pub password: String, + pub device_id: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] @@ -28,6 +30,7 @@ pub struct SyncCapabilities { pub bulk_sync: u8, pub history_page_size: u32, pub key_pairing: u8, + pub account_sessions: u8, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -80,6 +83,7 @@ pub struct ClaimPairingSession { pub recipient_public_key: String, pub recipient_device_id: String, pub recipient_device_name: String, + pub encrypted_device_info: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -119,6 +123,36 @@ pub struct DeleteUser { pub password: String, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UpdateAccountSession { + pub encrypted_device_info: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ChangePassword { + pub current_password: String, + pub new_password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct AccountSessionResponse { + pub id: String, + pub device_id: String, + pub encrypted_device_info: Option, + pub created_at: i64, + pub last_active_at: i64, + pub expires_at: i64, + pub current: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct AccountSessionsResponse { + pub sessions: Vec, + pub password_login: bool, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] pub struct CreatePlaylist { pub id: Option, @@ -263,5 +297,8 @@ pub enum WatchedState { pub struct JwtClaims { /// User ID. pub sub: String, + /// Database-backed account session ID. + #[serde(default)] + pub jti: Option, pub exp: usize, } diff --git a/src/handlers.rs b/src/handlers.rs index cafa2a1..449fc76 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -17,6 +17,7 @@ pub mod health; pub mod pairing; pub mod playlist_bookmarks; pub mod playlists; +pub mod session; pub mod subscriptions; pub mod user; pub mod watch_history; @@ -75,6 +76,8 @@ pub enum HandlerError { EncryptedSyncRequired, #[error("pairing session not found or expired")] PairingNotFound, + #[error("account session not found or expired")] + AccountSessionNotFound, #[error("pairing session has already changed state")] PairingConflict, #[error("too many active pairing sessions")] @@ -116,6 +119,7 @@ impl ResponseError for HandlerError { Self::EncryptedSyncQuotaExceeded => StatusCode::PAYLOAD_TOO_LARGE, Self::EncryptedSyncRequired => StatusCode::CONFLICT, Self::PairingNotFound => StatusCode::NOT_FOUND, + Self::AccountSessionNotFound => StatusCode::NOT_FOUND, Self::PairingConflict => StatusCode::CONFLICT, Self::PairingLimitExceeded => StatusCode::TOO_MANY_REQUESTS, Self::YouTubeConnectError => StatusCode::INTERNAL_SERVER_ERROR, @@ -198,6 +202,22 @@ impl FromRequest for Account { } } +impl FromRequest for crate::models::AccountSession { + type Error = actix_web::Error; + + type Future = Pin>>>; + + fn from_request(req: &HttpRequest, _payload: &mut actix_web::dev::Payload) -> Self::Future { + let extensions = req.extensions(); + let session = extensions.get::().cloned(); + Box::pin(async move { + session.ok_or(actix_web::error::ErrorForbidden( + "missing account session info", + )) + }) + } +} + #[macro_export] macro_rules! get_db_conn { ($pool:ident) => { diff --git a/src/handlers/encrypted_sync.rs b/src/handlers/encrypted_sync.rs index 433e90e..0e2587e 100644 --- a/src/handlers/encrypted_sync.rs +++ b/src/handlers/encrypted_sync.rs @@ -57,6 +57,7 @@ pub(crate) fn sync_capabilities() -> SyncCapabilities { bulk_sync: 1, history_page_size: MAX_PAGE_SIZE, key_pairing: 1, + account_sessions: 1, } } diff --git a/src/handlers/pairing.rs b/src/handlers/pairing.rs index 352c0e9..9d0c689 100644 --- a/src/handlers/pairing.rs +++ b/src/handlers/pairing.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use actix_web::body::MessageBody; use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse}; @@ -10,33 +10,32 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use sha2::{Digest, Sha256}; use utoipa_actix_web::scope; -use crate::auth::generate_jwt; use crate::database::pairing; use crate::dto::{ ApprovePairingSession, ClaimPairingSession, CreatePairingSession, PairingClaimResponse, PairingPayloadResponse, PairingSessionResponse, }; -use crate::handlers::user::{authenticate_account, request_within_rate_limit}; +use crate::handlers::session::{is_base64url, now_ms, validate_device_id, validate_device_name}; +use crate::handlers::user::{ + authenticate_account, new_pairing_account_session, request_within_rate_limit, session_token, + validate_encrypted_device_info, +}; use crate::handlers::{HandlerError, HandlerResult, ScopedHandler}; use crate::models::{Account, PairingSession}; use crate::rate_limit::RateLimiter; -use crate::{CONFIG, WebData, get_db_conn}; +use crate::{WebData, get_db_conn}; const PAIRING_TTL_MS: i64 = 2 * 60 * 1000; const PAIRING_PROTOCOL_VERSION: u8 = 1; const SESSION_ID_BYTES: usize = 32; const PUBLIC_KEY_BYTES: usize = 32; -const DEVICE_ID_BYTES: usize = 16; const RECIPIENT_TOKEN_BYTES: usize = 32; const RECIPIENT_TOKEN_HEADER: &str = "X-Pairing-Token"; -const MAX_DEVICE_NAME_CHARS: usize = 80; -const MAX_DEVICE_NAME_BYTES: usize = 240; const MIN_ENCRYPTED_PAYLOAD_BYTES: usize = 96; const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 1536; const MAX_ENCRYPTED_PAYLOAD_LENGTH: usize = 2048; const MAX_PAIRING_REQUESTS_PER_MINUTE: u32 = 120; const MAX_TRACKED_ACCOUNTS: usize = 100_000; -const PAIRING_CLEANUP_INTERVAL: Duration = Duration::from_secs(30); struct PairingRateWindow { started_at: Instant, @@ -78,26 +77,6 @@ static PAIRING_RATE_LIMITER: LazyLock = LazyLock::new(|| Pai windows: Mutex::new(HashMap::new()), }); -pub fn start_expired_session_cleanup(pool: crate::DbPool) { - actix_web::rt::spawn(async move { - let mut interval = actix_web::rt::time::interval(PAIRING_CLEANUP_INTERVAL); - loop { - interval.tick().await; - let Ok(now) = now_ms() else { - log::error!("could not determine the time for pairing-session cleanup"); - continue; - }; - let Ok(mut conn) = pool.get().await else { - log::error!("could not get a database connection for pairing-session cleanup"); - continue; - }; - if let Err(error) = pairing::delete_expired(&mut conn, now).await { - log::error!("could not delete expired pairing sessions: {error}"); - } - } - }); -} - pub struct PairingHandler {} impl ScopedHandler for PairingHandler { @@ -121,14 +100,6 @@ impl ScopedHandler for PairingHandler { } } -fn now_ms() -> HandlerResult { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| HandlerError::InternalDatabaseError)? - .as_millis(); - i64::try_from(millis).map_err(|_| HandlerError::InternalDatabaseError) -} - fn check_account_rate_limit(account: &Account) -> HandlerResult<()> { if !PAIRING_RATE_LIMITER.check(&account.id) { return Err(HandlerError::TooManyRequests); @@ -136,12 +107,6 @@ fn check_account_rate_limit(account: &Account) -> HandlerResult<()> { Ok(()) } -fn is_base64url(value: &str, expected_length: usize) -> bool { - URL_SAFE_NO_PAD - .decode(value) - .is_ok_and(|bytes| bytes.len() == expected_length && URL_SAFE_NO_PAD.encode(bytes) == value) -} - fn validate_session_id(value: &str) -> HandlerResult<()> { if !is_base64url(value, SESSION_ID_BYTES) { return Err(HandlerError::ValidationErrorWithContext( @@ -151,29 +116,6 @@ fn validate_session_id(value: &str) -> HandlerResult<()> { Ok(()) } -fn validate_device_id(value: &str) -> HandlerResult<()> { - if !is_base64url(value, DEVICE_ID_BYTES) { - return Err(HandlerError::ValidationErrorWithContext( - "invalid pairing device id".to_owned(), - )); - } - Ok(()) -} - -fn validate_device_name(value: &str) -> HandlerResult<()> { - if value.is_empty() - || value.trim() != value - || value.chars().count() > MAX_DEVICE_NAME_CHARS - || value.len() > MAX_DEVICE_NAME_BYTES - || value.chars().any(char::is_control) - { - return Err(HandlerError::ValidationErrorWithContext( - "invalid pairing device name".to_owned(), - )); - } - Ok(()) -} - fn validate_pairing_fields( version: u8, recipient_public_key: &str, @@ -216,7 +158,11 @@ fn validate_claim(form: &ClaimPairingSession) -> HandlerResult<()> { &form.recipient_public_key, &form.recipient_device_id, &form.recipient_device_name, - ) + )?; + if let Some(encrypted_device_info) = &form.encrypted_device_info { + validate_encrypted_device_info(encrypted_device_info)?; + } + Ok(()) } fn validate_approval(form: &ApprovePairingSession) -> HandlerResult<()> { @@ -330,8 +276,6 @@ async fn claim_pairing_session( check_account_rate_limit(&account)?; validate_session_id(&id)?; validate_claim(&form)?; - let jwt = generate_jwt(&account, CONFIG.secret.as_bytes()) - .map_err(|_| HandlerError::InternalDatabaseError)?; let candidate = PairingSession { id: id.into_inner(), version: i16::from(form.version), @@ -345,14 +289,30 @@ async fn claim_pairing_session( expires_at: 0, }; let mut conn = get_db_conn!(pool); - let session = match pairing::claim(&mut conn, &account.id, &candidate, now_ms()?) - .await - .map_err(|_| HandlerError::InternalDatabaseError)? + let account_session = new_pairing_account_session( + &account, + candidate.id.clone(), + candidate.recipient_device_id.clone(), + form.encrypted_device_info.clone(), + )?; + let (session, account_session) = match pairing::claim( + &mut conn, + &account.id, + &candidate, + &account_session, + now_ms()?, + ) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? { - pairing::ClaimResult::Claimed(session) => session, + pairing::ClaimResult::Claimed { + pairing, + account_session, + } => (pairing, account_session), pairing::ClaimResult::Conflict => return Err(HandlerError::PairingConflict), pairing::ClaimResult::LimitExceeded => return Err(HandlerError::PairingLimitExceeded), }; + let jwt = session_token(&account, &account_session)?; Ok(web::Json(PairingClaimResponse { session: response(*session), jwt, @@ -468,6 +428,7 @@ mod tests { recipient_public_key: create.recipient_public_key.clone(), recipient_device_id: create.recipient_device_id.clone(), recipient_device_name: create.recipient_device_name.clone(), + encrypted_device_info: Some("encrypted-device-info".to_owned()), }; assert!(validate_claim(&claim).is_ok()); diff --git a/src/handlers/session.rs b/src/handlers/session.rs new file mode 100644 index 0000000..8a49446 --- /dev/null +++ b/src/handlers/session.rs @@ -0,0 +1,106 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; + +use crate::database::{account_session, pairing}; +use crate::handlers::{HandlerError, HandlerResult}; + +pub const DEVICE_ID_BYTES: usize = 16; + +const MAX_DEVICE_NAME_CHARS: usize = 80; +const MAX_DEVICE_NAME_BYTES: usize = 240; + +#[derive(Clone, Copy)] +enum SessionKind { + Account, + Pairing, +} + +impl SessionKind { + fn label(self) -> &'static str { + match self { + Self::Account => "account", + Self::Pairing => "pairing", + } + } +} + +pub fn start_expired_session_cleanup(pool: crate::DbPool) { + spawn_cleanup( + pool.clone(), + Duration::from_secs(60 * 60), + SessionKind::Account, + ); + spawn_cleanup(pool, Duration::from_secs(30), SessionKind::Pairing); +} + +fn spawn_cleanup(pool: crate::DbPool, interval: Duration, kind: SessionKind) { + actix_web::rt::spawn(async move { + let mut interval = actix_web::rt::time::interval(interval); + loop { + interval.tick().await; + let Ok(now) = now_ms() else { + log::error!( + "could not determine the time for {}-session cleanup", + kind.label() + ); + continue; + }; + let Ok(mut conn) = pool.get().await else { + log::error!( + "could not get a database connection for {}-session cleanup", + kind.label() + ); + continue; + }; + let result = match kind { + SessionKind::Account => account_session::delete_expired(&mut conn, now).await, + SessionKind::Pairing => pairing::delete_expired(&mut conn, now).await, + }; + if let Err(error) = result { + log::error!( + "could not delete expired {} sessions: {error}", + kind.label() + ); + } + } + }); +} + +pub fn now_ms() -> HandlerResult { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| HandlerError::InternalDatabaseError)? + .as_millis(); + i64::try_from(millis).map_err(|_| HandlerError::InternalDatabaseError) +} + +pub fn is_base64url(value: &str, expected_length: usize) -> bool { + URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|bytes| bytes.len() == expected_length && URL_SAFE_NO_PAD.encode(bytes) == value) +} + +pub fn validate_device_id(value: &str) -> HandlerResult<()> { + if !is_base64url(value, DEVICE_ID_BYTES) { + return Err(HandlerError::ValidationErrorWithContext( + "invalid device id".to_owned(), + )); + } + Ok(()) +} + +pub fn validate_device_name(value: &str) -> HandlerResult<()> { + if value.is_empty() + || value.trim() != value + || value.chars().count() > MAX_DEVICE_NAME_CHARS + || value.len() > MAX_DEVICE_NAME_BYTES + || value.chars().any(char::is_control) + { + return Err(HandlerError::ValidationErrorWithContext( + "invalid device name".to_owned(), + )); + } + Ok(()) +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 645bc74..85b3b7f 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -1,24 +1,35 @@ -use std::net::{IpAddr, SocketAddr}; - use actix_web::body::MessageBody; use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse}; use actix_web::middleware::Next; use actix_web::web::Redirect; -use actix_web::{HttpMessage, HttpRequest, HttpResponse, Responder, delete, get, post, web}; +use actix_web::{ + HttpMessage, HttpRequest, HttpResponse, Responder, delete, get, patch, post, put, web, +}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use diesel::result::DatabaseErrorKind; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::net::{IpAddr, SocketAddr}; use utoipa_actix_web::scope; use uuid::Uuid; -use crate::auth::{generate_jwt, hash_accountname, hash_password, verify_jwt, verify_password}; +use crate::auth::{ + TOKEN_TTL, generate_jwt, hash_accountname, hash_password, unix_now, verify_jwt, verify_password, +}; use crate::database::account::{ delete_existing_account, delete_existing_account_by_oidc_sub, find_account_by_id, find_account_by_name_hash, insert_new_account, }; +use crate::database::account_session; use crate::database::encrypted_sync; -use crate::dto::LoginResponse; +use crate::dto::{ + AccountSessionResponse, AccountSessionsResponse, ChangePassword, JwtClaims, LoginResponse, + UpdateAccountSession, +}; +use crate::handlers::session::{DEVICE_ID_BYTES, now_ms, validate_device_id}; use crate::handlers::{HandlerError, HandlerResult, ScopedHandler}; -use crate::models::Account; +use crate::models::{Account, AccountSession}; use crate::oidc::check_oidc_auth_request; use crate::rate_limit::RateLimiter; use crate::{CONFIG, WebData, dto, get_db_conn, models, oidc}; @@ -26,6 +37,7 @@ use crate::{CONFIG, WebData, dto, get_db_conn, models, oidc}; const AUTH_HEADER_KEY: &str = "Authorization"; const MIN_PASSWORD_LENGTH: usize = 8; const OIDC_ACCOUNT_PREFIX: &str = "OIDC-ACCOUNT-"; +const MAX_ENCRYPTED_DEVICE_INFO_BYTES: usize = 1024; /// Marker registered on scopes that remain reachable after an account has /// switched to encrypted sync. @@ -35,6 +47,12 @@ const OIDC_ACCOUNT_PREFIX: &str = "OIDC-ACCOUNT-"; /// no plaintext sync data or are needed to manage the account itself. pub struct PlaintextSyncExempt; +#[derive(Clone)] +pub struct AuthenticatedSession { + pub account: Account, + pub session: AccountSession, +} + pub struct UserHandler {} impl ScopedHandler for UserHandler { fn get_service() -> scope::Scope< @@ -72,11 +90,159 @@ impl ScopedHandler for UserHandler { scope::scope("") .app_data(web::Data::new(PlaintextSyncExempt)) .wrap(actix_web::middleware::from_fn(auth_middleware)) - .service(delete_account), + .service(delete_account) + .service(list_account_sessions) + .service(update_account_session) + .service(revoke_account_session) + .service(change_password), ) } } +fn supplied_or_random_device_id(value: Option<&str>) -> HandlerResult { + if let Some(value) = value { + validate_device_id(value)?; + return Ok(value.to_owned()); + } + Ok(URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes())) +} + +pub(crate) fn validate_encrypted_device_info(value: &str) -> HandlerResult<()> { + if value.is_empty() || value.len() > MAX_ENCRYPTED_DEVICE_INFO_BYTES { + return Err(HandlerError::ValidationErrorWithContext( + "invalid encrypted device info".to_owned(), + )); + } + Ok(()) +} + +fn new_account_session( + account: &Account, + session_id: String, + device_id: String, + encrypted_device_info: Option, + generation: i64, + pending_pairing: bool, +) -> HandlerResult { + validate_device_id(&device_id)?; + if let Some(info) = &encrypted_device_info { + validate_encrypted_device_info(info)?; + } + let created_at = now_ms()?; + let expires_at_seconds = unix_now().saturating_add(TOKEN_TTL.as_secs()); + let expires_at = i64::try_from(expires_at_seconds) + .unwrap_or(i64::MAX / 1000) + .saturating_mul(1000); + Ok(AccountSession { + id: session_id, + account_id: account.id.clone(), + device_id, + encrypted_device_info, + created_at, + last_active_at: created_at, + expires_at, + revoked_at: None, + legacy: false, + generation, + pending_pairing, + }) +} + +pub(crate) fn session_token(account: &Account, session: &AccountSession) -> HandlerResult { + let expires_at = u64::try_from(session.expires_at / 1000) + .map_err(|_| HandlerError::InternalDatabaseError)?; + generate_jwt(account, &session.id, expires_at, CONFIG.secret.as_bytes()) + .map_err(|error| HandlerError::InternalDatabaseErrorWithContext(error.to_string())) +} + +fn legacy_session(account: &Account, token: &str, claims: &JwtClaims, now: i64) -> AccountSession { + let digest = Sha256::digest(token.as_bytes()); + let expires_at = i64::try_from(claims.exp) + .unwrap_or(i64::MAX / 1000) + .saturating_mul(1000); + let issued_at = + expires_at.saturating_sub(i64::try_from(TOKEN_TTL.as_millis()).unwrap_or(i64::MAX)); + AccountSession { + id: format!("legacy:{}", URL_SAFE_NO_PAD.encode(digest)), + account_id: account.id.clone(), + device_id: URL_SAFE_NO_PAD.encode(&digest[..DEVICE_ID_BYTES]), + encrypted_device_info: None, + created_at: issued_at, + last_active_at: now, + expires_at, + revoked_at: None, + legacy: true, + generation: account.session_generation, + pending_pairing: false, + } +} + +async fn resolve_account_session( + conn: &mut crate::DbConnection, + account: &Account, + token: &str, + claims: &JwtClaims, + now: i64, +) -> HandlerResult { + if let Some(session_id) = &claims.jti { + return account_session::find_active( + conn, + &account.id, + session_id, + account.session_generation, + now, + ) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + .ok_or(HandlerError::InvalidToken); + } + + if !account.legacy_tokens_enabled { + return Err(HandlerError::InvalidToken); + } + + let candidate = legacy_session(account, token, claims, now); + account_session::get_or_create(conn, &candidate) + .await + .map_err(|error| match error { + diesel::result::Error::NotFound => HandlerError::InvalidToken, + _ => HandlerError::InternalDatabaseError, + }) +} + +async fn issue_account_session( + conn: &mut crate::DbConnection, + account: &Account, + device_id: String, +) -> HandlerResult { + let session = new_account_session( + account, + Uuid::now_v7().to_string(), + device_id, + None, + account.session_generation, + false, + )?; + let session = account_session::create(conn, &session).await?; + session_token(account, &session) +} + +pub(crate) fn new_pairing_account_session( + account: &Account, + session_id: String, + device_id: String, + encrypted_device_info: Option, +) -> HandlerResult { + new_account_session( + account, + session_id, + device_id, + encrypted_device_info, + account.session_generation, + true, + ) +} + #[utoipa::path(responses((status = OK, body = LoginResponse)))] #[post("/register")] async fn register_account( @@ -98,12 +264,15 @@ async fn register_account( if password_length < MIN_PASSWORD_LENGTH { return Err(HandlerError::PasswordTooShort); } + let device_id = supplied_or_random_device_id(form.device_id.as_deref())?; let account = models::Account { id: Uuid::now_v7().to_string(), name_hash: hash_accountname(&form.name, CONFIG.username_secret().as_bytes()), password_hash: Some(hash_password(&form.password)), oidc_sub: None, + legacy_tokens_enabled: false, + session_generation: 0, }; let account = insert_new_account(&mut conn, &account) @@ -115,15 +284,18 @@ async fn register_account( _ => HandlerError::InternalDatabaseErrorWithContext(err.to_string()), })?; - match generate_jwt(&account, CONFIG.secret.as_bytes()) { - Ok(jwt) => { - let resp = LoginResponse { jwt }; - Ok(HttpResponse::Created().json(resp)) + let jwt = match issue_account_session(&mut conn, &account, device_id).await { + Ok(jwt) => jwt, + Err(error) => { + if let Err(cleanup_error) = delete_existing_account(&mut conn, &account.id).await { + log::error!( + "could not roll back account after session creation failed: {cleanup_error}" + ); + } + return Err(error); } - Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext( - err.to_string(), - )), - } + }; + Ok(HttpResponse::Created().json(LoginResponse { jwt })) } #[utoipa::path(responses((status = CREATED, body = LoginResponse)))] @@ -132,6 +304,7 @@ async fn login_account( pool: WebData, form: web::Json, ) -> HandlerResult { + let device_id = supplied_or_random_device_id(form.device_id.as_deref())?; let mut conn = get_db_conn!(pool); let name = hash_accountname(&form.name, CONFIG.username_secret().as_bytes()); @@ -151,15 +324,8 @@ async fn login_account( return Err(HandlerError::InvalidCredentials); } - match generate_jwt(&account, CONFIG.secret.as_bytes()) { - Ok(jwt) => { - let resp = LoginResponse { jwt }; - Ok(HttpResponse::Ok().json(resp)) - } - Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext( - err.to_string(), - )), - } + let jwt = issue_account_session(&mut conn, &account, device_id).await?; + Ok(HttpResponse::Ok().json(LoginResponse { jwt })) } #[utoipa::path(responses((status = OK)), security(("api_jwt_token" = [])))] @@ -187,6 +353,142 @@ async fn delete_account( } } +fn account_session_response( + session: AccountSession, + current_session_id: &str, +) -> AccountSessionResponse { + AccountSessionResponse { + current: session.id == current_session_id, + id: session.id, + device_id: session.device_id, + encrypted_device_info: session.encrypted_device_info, + created_at: session.created_at, + last_active_at: session.last_active_at, + expires_at: session.expires_at, + } +} + +#[utoipa::path(responses((status = OK, body = AccountSessionsResponse)), security(("api_jwt_token" = [])))] +#[get("/sessions")] +async fn list_account_sessions( + account: Account, + current_session: AccountSession, + pool: WebData, +) -> HandlerResult { + let mut conn = get_db_conn!(pool); + let sessions = account_session::list_active( + &mut conn, + &account.id, + account.session_generation, + now_ms()?, + ) + .await? + .into_iter() + .map(|session| account_session_response(session, ¤t_session.id)) + .collect(); + Ok(web::Json(AccountSessionsResponse { + sessions, + password_login: account.password_hash.is_some(), + })) +} + +#[utoipa::path(request_body = UpdateAccountSession, responses((status = NO_CONTENT)), security(("api_jwt_token" = [])))] +#[patch("/sessions/{id}")] +async fn update_account_session( + account: Account, + current_session: AccountSession, + pool: WebData, + id: web::Path, + form: web::Json, +) -> HandlerResult { + validate_encrypted_device_info(&form.encrypted_device_info)?; + let session_id = if id.as_str() == "current" { + ¤t_session.id + } else { + id.as_str() + }; + let mut conn = get_db_conn!(pool); + if !account_session::update_encrypted_info( + &mut conn, + &account.id, + session_id, + account.session_generation, + &form.encrypted_device_info, + now_ms()?, + ) + .await? + { + return Err(HandlerError::AccountSessionNotFound); + } + Ok(HttpResponse::NoContent()) +} + +#[utoipa::path(responses((status = NO_CONTENT)), security(("api_jwt_token" = [])))] +#[delete("/sessions/{id}")] +async fn revoke_account_session( + account: Account, + pool: WebData, + id: web::Path, +) -> HandlerResult { + let mut conn = get_db_conn!(pool); + if !account_session::revoke( + &mut conn, + &account.id, + &id, + account.session_generation, + now_ms()?, + ) + .await? + { + return Err(HandlerError::AccountSessionNotFound); + } + Ok(HttpResponse::NoContent()) +} + +#[utoipa::path(request_body = ChangePassword, responses((status = OK, body = LoginResponse)), security(("api_jwt_token" = [])))] +#[put("/password")] +async fn change_password( + account: Account, + current_session: AccountSession, + pool: WebData, + form: web::Json, +) -> HandlerResult { + let Some(password_hash) = &account.password_hash else { + return Err(HandlerError::PasswordLoginDisabledForAccount); + }; + if !verify_password(&form.current_password, password_hash) { + return Err(HandlerError::InvalidCredentials); + } + if form.new_password.len() < MIN_PASSWORD_LENGTH { + return Err(HandlerError::PasswordTooShort); + } + + let new_password_hash = hash_password(&form.new_password); + let replacement = new_account_session( + &account, + Uuid::now_v7().to_string(), + current_session.device_id.clone(), + current_session.encrypted_device_info.clone(), + account.session_generation.saturating_add(1), + false, + )?; + let mut conn = get_db_conn!(pool); + let jwt = session_token(&account, &replacement)?; + if !account_session::change_password_and_revoke_others( + &mut conn, + &account.id, + &replacement, + password_hash, + &new_password_hash, + now_ms()?, + ) + .await? + { + return Err(HandlerError::InvalidCredentials); + } + Ok(web::Json(LoginResponse { jwt })) +} + /// Middleware that rate limits unauthenticated endpoints per client address. /// /// Defaults to the peer address, since forwarded headers are client-controlled @@ -197,6 +499,13 @@ pub async fn rate_limit_middleware( req: ServiceRequest, next: Next, ) -> Result, actix_web::Error> { + // Listing, renaming, and revoking sessions already require a valid token. + // Counting those ordinary management requests against the credential-attempt + // budget can lock a signed-in user out midway through reviewing devices. + if req.path().ends_with("/account/sessions") || req.path().contains("/account/sessions/") { + return next.call(req).await; + } + let limiter: Option<&web::Data> = req.app_data(); if let Some(limiter) = limiter @@ -268,10 +577,10 @@ pub(crate) fn request_within_rate_limit(req: &HttpRequest, limiter: &RateLimiter client.is_none_or(|address| limiter.check(address)) } -pub(crate) async fn authenticate_account( +pub(crate) async fn authenticate_session( req: &HttpRequest, pool: &WebData, -) -> HandlerResult { +) -> HandlerResult { let auth_header = req .headers() .get(AUTH_HEADER_KEY) @@ -284,13 +593,35 @@ pub(crate) async fn authenticate_account( let jwt = auth_cookie .or(auth_header) .ok_or(HandlerError::InvalidToken)?; - let account_id = + let claims = verify_jwt(&jwt, CONFIG.secret.as_bytes()).map_err(|_| HandlerError::InvalidToken)?; let mut conn = get_db_conn!(pool); - find_account_by_id(&mut conn, &account_id) + let now = now_ms()?; + let account = find_account_by_id(&mut conn, &claims.sub) .await .map_err(|_| HandlerError::InternalDatabaseError)? - .ok_or(HandlerError::AccountNotExists) + .ok_or(HandlerError::AccountNotExists)?; + let mut session = resolve_account_session(&mut conn, &account, &jwt, &claims, now).await?; + if account_session::touch( + &mut conn, + &session.id, + account.session_generation, + session.last_active_at, + now, + ) + .await + .map_err(|_| HandlerError::InternalDatabaseError)? + { + session.last_active_at = now; + } + Ok(AuthenticatedSession { account, session }) +} + +pub(crate) async fn authenticate_account( + req: &HttpRequest, + pool: &WebData, +) -> HandlerResult { + Ok(authenticate_session(req, pool).await?.account) } /// Middleware that ensures that the account is authenticated. @@ -299,7 +630,9 @@ pub async fn auth_middleware( next: Next, ) -> Result, actix_web::Error> { let pool: WebData = req.app_data().cloned().unwrap(); - let account = authenticate_account(req.request(), &pool).await?; + let authenticated = authenticate_session(req.request(), &pool).await?; + let account = authenticated.account; + let session = authenticated.session; let mut conn = get_db_conn!(pool); // Scopes opt out explicitly. Matching on the request path instead would let @@ -325,6 +658,7 @@ pub async fn auth_middleware( // append account to request extensions so that it can be accessed with // `req.extensions().get::()` by handlers req.extensions_mut().insert(account); + req.extensions_mut().insert(session); next.call(req).await } @@ -339,6 +673,92 @@ mod tests { use super::{PlaintextSyncExempt, rate_limit_middleware}; use crate::rate_limit::RateLimiter; + #[cfg(feature = "sqlite")] + #[actix_rt::test] + async fn legacy_tokens_create_one_revocable_session() { + use diesel::connection::SimpleConnection; + use diesel_async::AsyncConnection; + use diesel_migrations::MigrationHarness; + + use super::{resolve_account_session, unix_now}; + use crate::database::account::find_account_by_id; + use crate::database::account_session; + use crate::dto::JwtClaims; + use crate::{DbConnection, MIGRATIONS}; + + let mut conn = DbConnection::establish(":memory:").await.unwrap(); + conn.spawn_blocking(|conn| { + conn.run_pending_migrations(MIGRATIONS).unwrap(); + conn.batch_execute( + "PRAGMA foreign_keys = ON; \ + INSERT INTO account (id, name_hash, password_hash, oidc_sub) \ + VALUES ('legacy-account', 'hash', 'password', NULL);", + )?; + Ok(()) + }) + .await + .unwrap(); + let account = find_account_by_id(&mut conn, "legacy-account") + .await + .unwrap() + .unwrap(); + let now = i64::try_from(unix_now()).unwrap() * 1000; + let claims = JwtClaims { + sub: account.id.clone(), + jti: None, + exp: usize::try_from(unix_now() + 60).unwrap(), + }; + + let first = resolve_account_session(&mut conn, &account, "legacy-token", &claims, now) + .await + .unwrap(); + let second = resolve_account_session(&mut conn, &account, "legacy-token", &claims, now) + .await + .unwrap(); + assert!(first.legacy); + assert_eq!(first.id, second.id); + assert_eq!( + account_session::list_active(&mut conn, &account.id, account.session_generation, now) + .await + .unwrap() + .len(), + 1 + ); + + assert!( + account_session::revoke( + &mut conn, + &account.id, + &first.id, + account.session_generation, + now, + ) + .await + .unwrap() + ); + assert!(matches!( + resolve_account_session(&mut conn, &account, "legacy-token", &claims, now).await, + Err(crate::handlers::HandlerError::InvalidToken) + )); + + conn.spawn_blocking(|conn| { + conn.batch_execute( + "UPDATE account SET legacy_tokens_enabled = FALSE \ + WHERE id = 'legacy-account';", + ) + }) + .await + .unwrap(); + let account = find_account_by_id(&mut conn, "legacy-account") + .await + .unwrap() + .unwrap(); + assert!(matches!( + resolve_account_session(&mut conn, &account, "unseen-legacy-token", &claims, now).await, + Err(crate::handlers::HandlerError::InvalidToken) + )); + } + // Nested so that `actix_web::test` imported above does not shadow the // built-in `#[test]` attribute for these synchronous tests. mod forwarded { @@ -403,6 +823,11 @@ mod tests { HttpResponse::NoContent() } + #[actix_web::get("/sessions")] + async fn stub_sessions() -> impl Responder { + HttpResponse::Ok() + } + /// Mirrors the real `/account` layout. An extra nested `scope("")` around /// register/login would match the prefix of its sibling and swallow /// `/account/delete`, so both routes must stay reachable. @@ -438,6 +863,54 @@ mod tests { ); } + #[actix_rt::test] + async fn session_management_does_not_spend_the_credential_attempt_budget() { + let app = test::init_service( + App::new().service( + web::scope("/account") + .app_data(web::Data::new(RateLimiter::new( + 1, + Duration::from_secs(3600), + ))) + .wrap(actix_web::middleware::from_fn(rate_limit_middleware)) + .service(stub_register) + .service(stub_sessions), + ), + ) + .await; + + let peer: SocketAddr = "203.0.113.9:5000".parse().unwrap(); + for _ in 0..3 { + let request = test::TestRequest::get() + .uri("/account/sessions") + .peer_addr(peer) + .to_request(); + assert_eq!( + test::call_service(&app, request).await.status(), + StatusCode::OK + ); + } + + let first_register = test::TestRequest::post() + .uri("/account/register") + .peer_addr(peer) + .to_request(); + assert_eq!( + test::call_service(&app, first_register).await.status(), + StatusCode::CREATED + ); + + let second_register = test::TestRequest::post() + .uri("/account/register") + .peer_addr(peer) + .to_request(); + let status = match test::try_call_service(&app, second_register).await { + Ok(response) => response.status(), + Err(error) => error.as_response_error().status_code(), + }; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + } + /// Mirrors how `auth_middleware` decides whether a scope is exempt from the /// encrypted-sync requirement, without needing a database. async fn exemption_probe( @@ -612,6 +1085,13 @@ struct OidcAuthenticationRequest { /// Url to redirect to once authentication succeeded. /// Passes a `token` query parameter to the URL, which is a valid JWT for the authenticated account. redirect_url: String, + device_id: Option, +} + +#[derive(Serialize, Deserialize)] +struct OidcCallbackContext { + redirect_url: String, + device_id: Option, } #[utoipa::path] @@ -620,14 +1100,20 @@ async fn authenticate_oidc_account( req: HttpRequest, query: web::Query, ) -> HandlerResult { + let device_id = supplied_or_random_device_id(query.device_id.as_deref())?; let callback_route = req .url_for::<&[_; 0], &String>("authenticate_oidc_account_callback", &[]) .unwrap(); + let callback_context = serde_json::to_string(&OidcCallbackContext { + redirect_url: query.redirect_url.clone(), + device_id: Some(device_id), + }) + .map_err(|_| HandlerError::InternalDatabaseError)?; let redirect_url = oidc::authenticate_oidc_user_request( &CONFIG.oidc.clone().unwrap(), callback_route.path(), - query.redirect_url.clone(), + callback_context, ) .await .map_err(HandlerError::OidcError)?; @@ -657,9 +1143,13 @@ async fn authenticate_oidc_account_callback( ) -> HandlerResult { let mut conn = get_db_conn!(pool); - let (user_claims, redirect_url) = check_oidc_auth_request(&query.state, query.code.clone()) + let (user_claims, callback_context) = check_oidc_auth_request(&query.state, query.code.clone()) .await .map_err(HandlerError::OidcError)?; + let context: OidcCallbackContext = + serde_json::from_str(&callback_context).map_err(|_| HandlerError::ValidationError)?; + let device_id = context.device_id.ok_or(HandlerError::ValidationError)?; + validate_device_id(&device_id)?; let oidc_sub = user_claims.subject().as_str(); @@ -678,6 +1168,8 @@ async fn authenticate_oidc_account_callback( // but unfortunately SQLite doesn't have a statement to alter table columns... password_hash: None, oidc_sub: Some(oidc_sub.to_string()), + legacy_tokens_enabled: false, + session_generation: 0, }; insert_new_account(&mut conn, &account) .await @@ -686,12 +1178,11 @@ async fn authenticate_oidc_account_callback( account }; - match generate_jwt(&account, CONFIG.secret.as_bytes()) { - Ok(jwt) => Ok(Redirect::to(format!("{redirect_url}?token={jwt}"))), - Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext( - err.to_string(), - )), - } + let jwt = issue_account_session(&mut conn, &account, device_id).await?; + Ok(Redirect::to(format!( + "{}?token={jwt}", + context.redirect_url + ))) } #[utoipa::path] @@ -704,10 +1195,15 @@ async fn delete_oidc_account( .url_for::<&[_; 0], &String>("delete_oidc_account_callback", &[]) .unwrap(); + let callback_context = serde_json::to_string(&OidcCallbackContext { + redirect_url: query.redirect_url.clone(), + device_id: None, + }) + .map_err(|_| HandlerError::InternalDatabaseError)?; let redirect_url = oidc::authenticate_oidc_user_request( &CONFIG.oidc.clone().unwrap(), callback_route.path(), - query.redirect_url.clone(), + callback_context, ) .await .map_err(HandlerError::OidcError)?; @@ -723,16 +1219,18 @@ async fn delete_oidc_account_callback( ) -> HandlerResult { let mut conn = get_db_conn!(pool); - let (user_claims, redirect_url) = check_oidc_auth_request(&query.state, query.code.clone()) + let (user_claims, callback_context) = check_oidc_auth_request(&query.state, query.code.clone()) .await .map_err(HandlerError::OidcError)?; + let context: OidcCallbackContext = + serde_json::from_str(&callback_context).map_err(|_| HandlerError::ValidationError)?; let oidc_sub = user_claims.subject().as_str(); match delete_existing_account_by_oidc_sub(&mut conn, oidc_sub).await { Ok(deleted) => { if deleted { - Ok(Redirect::to(redirect_url)) + Ok(Redirect::to(context.redirect_url)) } else { Err(HandlerError::AccountNotExists) } diff --git a/src/main.rs b/src/main.rs index f6b230b..8dc1169 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,7 +86,7 @@ async fn main() -> io::Result<()> { CONFIG.migration_approval.as_deref(), ) .await; - handlers::pairing::start_expired_session_cleanup(pool.clone()); + handlers::session::start_expired_session_cleanup(pool.clone()); if let Some(oidc) = &CONFIG.oidc { init_oidc(oidc).await; diff --git a/src/models.rs b/src/models.rs index 0b1a340..e5d72c0 100644 --- a/src/models.rs +++ b/src/models.rs @@ -27,6 +27,39 @@ pub struct Account { pub password_hash: Option, #[serde(skip_serializing)] pub oidc_sub: Option, + #[serde(skip_serializing)] + pub legacy_tokens_enabled: bool, + #[serde(skip_serializing)] + pub session_generation: i64, +} + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + Queryable, + Selectable, + Insertable, + AsChangeset, + ToSchema, + Eq, + PartialEq, +)] +#[diesel(belongs_to(Account))] +#[diesel(table_name = account_session)] +pub struct AccountSession { + pub id: String, + pub account_id: String, + pub device_id: String, + pub encrypted_device_info: Option, + pub created_at: i64, + pub last_active_at: i64, + pub expires_at: i64, + pub revoked_at: Option, + pub legacy: bool, + pub generation: i64, + pub pending_pairing: bool, } #[derive( diff --git a/src/schema.rs b/src/schema.rs index fc40b7f..dd3fce2 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,5 +1,21 @@ // @generated automatically by Diesel CLI. +diesel::table! { + account_session (id) { + id -> Text, + account_id -> Text, + device_id -> Text, + encrypted_device_info -> Nullable, + created_at -> BigInt, + last_active_at -> BigInt, + expires_at -> BigInt, + revoked_at -> Nullable, + legacy -> Bool, + generation -> BigInt, + pending_pairing -> Bool, + } +} + diesel::table! { encrypted_sync (account_id, collection) { account_id -> Text, @@ -30,6 +46,8 @@ diesel::table! { name_hash -> Text, password_hash -> Nullable, oidc_sub -> Nullable, + legacy_tokens_enabled -> Bool, + session_generation -> BigInt, } } @@ -130,6 +148,7 @@ diesel::table! { } diesel::joinable!(playlist -> account (account_id)); +diesel::joinable!(account_session -> account (account_id)); diesel::joinable!(channel_playback_speed -> account (account_id)); diesel::joinable!(encrypted_sync -> account (account_id)); diesel::joinable!(pairing_session -> account (account_id)); @@ -149,6 +168,7 @@ diesel::joinable!(watch_history -> video (video_id)); diesel::allow_tables_to_appear_in_same_query!( account, + account_session, channel, channel_playback_speed, encrypted_sync,