//! Passwordless authentication, enrollment and secret-storage domain service. //! //! This module owns invite redemption, TOTP replay protection, recovery codes, //! session issuance and tenant credential encryption. Raw sessions, recovery //! codes and component tokens are returned only at creation time; persistent //! rows contain hashes or authenticated ciphertext. HTTP-specific cookie and //! origin policy deliberately live in `http_api`, not here. use std::{fmt, sync::Arc, time::Duration}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use chacha20poly1305::{ Key, XChaCha20Poly1305, XNonce, aead::{Aead, KeyInit, Payload}, }; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; use tokio_postgres::{Transaction, error::SqlState}; use totp_rs::{Algorithm, Secret, TOTP}; use uuid::Uuid; use crate::{ credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host}, db::{Db, DbError}, gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings}, gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings}, i18n, overlay::OverlaySettings, song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings}, }; const TOTP_DIGITS: usize = 6; const TOTP_PERIOD_SECONDS: u64 = 30; const RECOVERY_CODE_COUNT: usize = 10; const MAX_PENDING_TOTP_FAILURES: i32 = 12; const ENROLLMENT_LOCK_ID: i64 = 1_280_529_236; /// Authentication/domain service intended to be cloned into Axum `AppState`. /// It owns no plaintext durable secret: raw tokens are returned once, while the /// database receives SHA-256 digests or XChaCha20-Poly1305 ciphertext only. #[derive(Clone)] pub struct AuthService { db: Db, cipher: SecretCipher, issuer: Arc, session_ttl: Duration, enrollment_ttl: Duration, } impl AuthService { pub fn new( db: Db, master_key: [u8; 32], issuer: impl Into, session_ttl: Duration, enrollment_ttl: Duration, ) -> Result { let issuer = issuer.into().trim().to_owned(); if issuer.is_empty() { return Err(AuthError::InvalidInput("TOTP issuer cannot be empty")); } Ok(Self { db, cipher: SecretCipher::new(master_key), issuer: issuer.into(), session_ttl, enrollment_ttl, }) } pub fn from_base64_master_key( db: Db, encoded_key: &str, issuer: impl Into, session_ttl: Duration, enrollment_ttl: Duration, ) -> Result { let bytes = URL_SAFE_NO_PAD .decode(encoded_key.trim()) .or_else(|_| base64::engine::general_purpose::STANDARD.decode(encoded_key.trim())) .map_err(|_| AuthError::InvalidMasterKey)?; let key: [u8; 32] = bytes.try_into().map_err(|_| AuthError::InvalidMasterKey)?; Self::new(db, key, issuer, session_ttl, enrollment_ttl) } pub fn db(&self) -> &Db { &self.db } pub async fn has_system_admin(&self) -> Result { let client = self.db.get().await?; Ok(client .query_one( "SELECT EXISTS(SELECT 1 FROM users WHERE role='system_admin' AND status='active')", &[], ) .await? .get(0)) } /// Creates the only invitation that can grant `system_admin`. The caller /// must additionally protect this operation with the one-time deployment /// bootstrap proof. It is rejected after any account exists. pub async fn create_bootstrap_invitation( &self, room_id: &str, valid_for: Duration, ) -> Result { validate_room_id(room_id)?; let token = random_token("inv", 32); let digest = token_digest(&token); let id = Uuid::new_v4(); let expires_at = future_time(valid_for)?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; transaction .query_one("SELECT pg_advisory_xact_lock($1)", &[&ENROLLMENT_LOCK_ID]) .await?; if transaction .query_one("SELECT EXISTS(SELECT 1 FROM users)", &[]) .await? .get::<_, bool>(0) { return Err(AuthError::BootstrapAlreadyCompleted); } transaction .execute( "DELETE FROM pending_registrations WHERE invitation_id IN \ (SELECT id FROM invitations WHERE grant_role='system_admin')", &[], ) .await?; transaction .execute( "UPDATE invitations SET revoked_at=now() \ WHERE grant_role='system_admin' AND consumed_at IS NULL AND revoked_at IS NULL", &[], ) .await?; transaction .execute( "INSERT INTO invitations \ (id,code_digest,code_prefix,room_id,grant_role,created_by,expires_at) \ VALUES($1,$2,$3,$4,'system_admin',NULL,$5)", &[&id, &digest, &token_prefix(&token), &room_id, &expires_at], ) .await?; insert_audit( &transaction, None, "invitation.bootstrap.created", "invitation", Some(id.to_string()), json!({"roomId":room_id}), ) .await?; transaction.commit().await?; Ok(CreatedInvitation { id, code: token, room_id: room_id.to_owned(), expires_at, }) } /// Creates a one-time invitation for a normal account. Only an active /// `system_admin` may call this method successfully. pub async fn create_invitation( &self, actor_user_id: Uuid, room_id: &str, valid_for: Duration, ) -> Result { validate_room_id(room_id)?; let token = random_token("inv", 32); let digest = token_digest(&token); let id = Uuid::new_v4(); let expires_at = future_time(valid_for)?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; require_system_admin(&transaction, actor_user_id).await?; if transaction .query_one( "SELECT EXISTS(SELECT 1 FROM users WHERE room_id=$1)", &[&room_id], ) .await? .get::<_, bool>(0) { return Err(AuthError::RoomUnavailable); } // A newly generated invitation supersedes an abandoned invitation for // the same room, without ever needing its plaintext code. transaction .execute( "DELETE FROM pending_registrations WHERE room_id=$1", &[&room_id], ) .await?; transaction .execute( "UPDATE invitations SET revoked_at=now() \ WHERE room_id=$1 AND consumed_at IS NULL AND revoked_at IS NULL", &[&room_id], ) .await?; transaction .execute( "INSERT INTO invitations \ (id,code_digest,code_prefix,room_id,grant_role,created_by,expires_at) \ VALUES($1,$2,$3,$4,'user',$5,$6)", &[ &id, &digest, &token_prefix(&token), &room_id, &actor_user_id, &expires_at, ], ) .await?; insert_audit( &transaction, Some(actor_user_id), "invitation.created", "invitation", Some(id.to_string()), json!({"roomId":room_id}), ) .await?; transaction.commit().await?; Ok(CreatedInvitation { id, code: token, room_id: room_id.to_owned(), expires_at, }) } pub async fn revoke_invitation( &self, actor_user_id: Uuid, invitation_id: Uuid, ) -> Result<(), AuthError> { let mut client = self.db.get().await?; let transaction = client.transaction().await?; require_system_admin(&transaction, actor_user_id).await?; let changed = transaction .execute( "UPDATE invitations SET revoked_at=now() \ WHERE id=$1 AND consumed_at IS NULL AND revoked_at IS NULL", &[&invitation_id], ) .await?; if changed == 0 { return Err(AuthError::InvitationUnavailable); } transaction .execute( "DELETE FROM pending_registrations WHERE invitation_id=$1", &[&invitation_id], ) .await?; insert_audit( &transaction, Some(actor_user_id), "invitation.revoked", "invitation", Some(invitation_id.to_string()), json!({}), ) .await?; transaction.commit().await?; Ok(()) } pub async fn list_invitations( &self, actor_user_id: Uuid, ) -> Result, AuthError> { let mut client = self.db.get().await?; let transaction = client.transaction().await?; require_system_admin(&transaction, actor_user_id).await?; let rows = transaction .query( "SELECT id,code_prefix,room_id,created_at,expires_at,consumed_at,revoked_at \ FROM invitations ORDER BY created_at DESC LIMIT 500", &[], ) .await?; transaction.commit().await?; let now = Utc::now(); Ok(rows .into_iter() .map(|row| { let expires_at: DateTime = row.get(4); let consumed_at: Option> = row.get(5); let revoked_at: Option> = row.get(6); let state = if consumed_at.is_some() { InvitationState::Consumed } else if revoked_at.is_some() { InvitationState::Revoked } else if expires_at <= now { InvitationState::Expired } else { InvitationState::Available }; InvitationSummary { id: row.get(0), code_prefix: row.get(1), room_id: row.get(2), created_at: row.get(3), expires_at, state, } }) .collect()) } /// Starts passwordless registration. The QR data URL, otpauth URL and /// Base32 secret are shown exactly at this stage and are never persisted in /// plaintext. An invitation is reserved after this call, so its QR cannot /// be silently replaced; an administrator must revoke/reissue an abandoned /// registration. pub async fn registration_start( &self, invitation_code: &str, username: &str, ) -> Result { let (username, username_normalized) = normalize_username(username)?; let invitation_digest = token_digest(invitation_code.trim()); self.preflight_registration(&invitation_digest, &username_normalized) .await?; let registration_id = Uuid::new_v4(); let enrollment_token = random_token("enroll", 32); let enrollment_digest = token_digest(&enrollment_token); let secret = Secret::generate_secret(); let secret_bytes = secret .to_bytes() .map_err(|error| AuthError::Totp(error.to_string()))?; let encoded = secret.to_encoded(); let encoded_secret = match &encoded { Secret::Encoded(value) => value.clone(), Secret::Raw(_) => unreachable!("to_encoded always returns Secret::Encoded"), }; let totp = build_totp(&secret_bytes, &self.issuer, &username)?; let otpauth_uri = totp.get_url(); let qr = totp .get_qr_base64() .map_err(|error| AuthError::Totp(error.to_string()))?; let qr_data_url = if qr.starts_with("data:") { qr } else { format!("data:image/png;base64,{qr}") }; let encrypted = self.cipher.encrypt( &secret_bytes, format!("pending-totp:{registration_id}").as_bytes(), )?; let expires_at = future_time(self.enrollment_ttl)?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; transaction .execute( "DELETE FROM pending_registrations WHERE expires_at <= now()", &[], ) .await?; let invitation = transaction .query_opt( "SELECT id,room_id,expires_at,consumed_at,revoked_at \ FROM invitations WHERE code_digest=$1 FOR UPDATE", &[&invitation_digest], ) .await? .ok_or(AuthError::InvitationUnavailable)?; let invitation_id: Uuid = invitation.get(0); let room_id: String = invitation.get(1); let invitation_expires_at: DateTime = invitation.get(2); let consumed_at: Option> = invitation.get(3); let revoked_at: Option> = invitation.get(4); if consumed_at.is_some() || revoked_at.is_some() || invitation_expires_at <= Utc::now() { return Err(AuthError::InvitationUnavailable); } if transaction .query_one( "SELECT EXISTS(SELECT 1 FROM users \ WHERE username_normalized=$1 OR room_id=$2)", &[&username_normalized, &room_id], ) .await? .get::<_, bool>(0) { return Err(AuthError::AccountUnavailable); } if transaction .query_one( "SELECT EXISTS(SELECT 1 FROM pending_registrations \ WHERE username_normalized=$1 OR invitation_id=$2)", &[&username_normalized, &invitation_id], ) .await? .get::<_, bool>(0) { return Err(AuthError::AccountUnavailable); } transaction .execute( "INSERT INTO pending_registrations \ (id,enrollment_token_digest,invitation_id,username,username_normalized,room_id,\ totp_secret_ciphertext,totp_secret_nonce,expires_at) \ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)", &[ ®istration_id, &enrollment_digest, &invitation_id, &username, &username_normalized, &room_id, &encrypted.ciphertext, &encrypted.nonce, &expires_at, ], ) .await?; transaction.commit().await?; Ok(RegistrationStart { enrollment_token, username, room_id, secret: encoded_secret, otpauth_uri, qr_data_url, expires_at, }) } /// Reject invalid or already-reserved invitations before generating a QR /// image. The transaction below repeats these checks under row locks; this /// preflight exists to keep anonymous invalid-code traffic inexpensive. async fn preflight_registration( &self, invitation_digest: &[u8], username_normalized: &str, ) -> Result<(), AuthError> { let client = self.db.get().await?; let invitation = client .query_opt( "SELECT id,room_id FROM invitations \ WHERE code_digest=$1 AND consumed_at IS NULL AND revoked_at IS NULL \ AND expires_at>now()", &[&invitation_digest], ) .await? .ok_or(AuthError::InvitationUnavailable)?; let invitation_id: Uuid = invitation.get(0); let room_id: String = invitation.get(1); if client .query_one( "SELECT EXISTS(\ SELECT 1 FROM users WHERE username_normalized=$1 OR room_id=$2 \ UNION ALL \ SELECT 1 FROM pending_registrations \ WHERE expires_at>now() AND (username_normalized=$1 OR invitation_id=$3)\ )", &[&username_normalized, &room_id, &invitation_id], ) .await? .get::<_, bool>(0) { return Err(AuthError::AccountUnavailable); } Ok(()) } pub async fn registration_confirm( &self, enrollment_token: &str, totp_code: &str, language: &str, ) -> Result { if !i18n::is_supported(language) { return Err(AuthError::InvalidInput("language is not supported")); } let digest = token_digest(enrollment_token.trim()); let mut client = self.db.get().await?; let transaction = client.transaction().await?; // Enrollment is rare and low-volume. Serializing confirmation closes // bootstrap races without relying solely on the unique admin index. transaction .query_one("SELECT pg_advisory_xact_lock($1)", &[&ENROLLMENT_LOCK_ID]) .await?; let row = transaction .query_opt( "SELECT p.id,p.invitation_id,p.username,p.username_normalized,p.room_id,\ p.totp_secret_ciphertext,p.totp_secret_nonce,p.expires_at,p.failed_attempts,\ i.grant_role,i.expires_at,i.consumed_at,i.revoked_at \ FROM pending_registrations p \ JOIN invitations i ON i.id=p.invitation_id \ WHERE p.enrollment_token_digest=$1 FOR UPDATE OF p,i", &[&digest], ) .await? .ok_or(AuthError::EnrollmentUnavailable)?; let registration_id: Uuid = row.get(0); let invitation_id: Uuid = row.get(1); let username: String = row.get(2); let username_normalized: String = row.get(3); let room_id: String = row.get(4); let ciphertext: Vec = row.get(5); let nonce: Vec = row.get(6); let enrollment_expires: DateTime = row.get(7); let failed_attempts: i32 = row.get(8); let role = UserRole::parse(row.get::<_, String>(9).as_str())?; let invitation_expires: DateTime = row.get(10); let invitation_consumed: Option> = row.get(11); let invitation_revoked: Option> = row.get(12); if enrollment_expires <= Utc::now() || invitation_expires <= Utc::now() || invitation_consumed.is_some() || invitation_revoked.is_some() { return Err(AuthError::EnrollmentUnavailable); } if role == UserRole::SystemAdmin && transaction .query_one( "SELECT EXISTS(SELECT 1 FROM users WHERE role='system_admin')", &[], ) .await? .get::<_, bool>(0) { return Err(AuthError::BootstrapAlreadyCompleted); } let secret = self.cipher.decrypt( &EncryptedSecret { ciphertext, nonce }, format!("pending-totp:{registration_id}").as_bytes(), )?; let accepted_step = accepted_totp_step( &secret, &self.issuer, &username, totp_code, Utc::now().timestamp(), None, )?; let Some(accepted_step) = accepted_step else { if failed_attempts + 1 >= MAX_PENDING_TOTP_FAILURES { transaction .execute( "DELETE FROM pending_registrations WHERE id=$1", &[®istration_id], ) .await?; } else { transaction .execute( "UPDATE pending_registrations SET failed_attempts=failed_attempts+1 WHERE id=$1", &[®istration_id], ) .await?; } transaction.commit().await?; return Err(AuthError::InvalidTotp); }; let user_id = Uuid::new_v4(); let encrypted = self .cipher .encrypt(&secret, format!("user-totp:{user_id}").as_bytes())?; transaction .execute( "INSERT INTO users \ (id,username,username_normalized,room_id,role,status,totp_secret_ciphertext,\ totp_secret_nonce,last_totp_step,totp_enrolled_at) \ VALUES($1,$2,$3,$4,$5,'active',$6,$7,$8,now())", &[ &user_id, &username, &username_normalized, &room_id, &role.as_str(), &encrypted.ciphertext, &encrypted.nonce, &accepted_step, ], ) .await?; let consumed = transaction .execute( "UPDATE invitations SET consumed_by=$1,consumed_at=now() \ WHERE id=$2 AND consumed_at IS NULL AND revoked_at IS NULL", &[&user_id, &invitation_id], ) .await?; if consumed != 1 { return Err(AuthError::EnrollmentUnavailable); } let recovery_codes = replace_recovery_codes(&transaction, user_id).await?; let session = insert_session(&transaction, user_id, self.session_ttl).await?; Db::set_tenant(&transaction, user_id).await?; transaction .execute( "INSERT INTO account_preferences(user_id,language) VALUES($1,$2)", &[&user_id, &language], ) .await?; let default_source_id = Uuid::new_v4(); transaction .execute( "INSERT INTO live_sources(id,owner_user_id,provider,room_id) \ VALUES($1,$2,'bilibili',$3)", &[&default_source_id, &user_id, &room_id], ) .await?; let default_component_id = Uuid::new_v4(); let default_settings = serde_json::to_value(OverlaySettings::default()) .expect("OverlaySettings is always JSON serializable"); transaction .execute( "INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \ VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)", &[&default_component_id, &user_id, &default_settings], ) .await?; let song_component_id = Uuid::new_v4(); let song_settings = serde_json::to_value(SongRequestSettings::default()) .expect("SongRequestSettings is always JSON serializable"); transaction .execute( "INSERT INTO component_instances \ (id,owner_user_id,kind,name,settings,settings_version,enabled) \ VALUES($1,$2,$3,$4,$5,1,true)", &[ &song_component_id, &user_id, &SONG_REQUEST_KIND, &SONG_REQUEST_NAME, &song_settings, ], ) .await?; transaction .execute( "INSERT INTO song_request_state(owner_user_id,component_instance_id) VALUES($1,$2)", &[&user_id, &song_component_id], ) .await?; let gift_component_id = Uuid::new_v4(); let gift_settings = serde_json::to_value(GiftEffectSettings::default()) .expect("GiftEffectSettings is always JSON serializable"); transaction .execute( "INSERT INTO component_instances \ (id,owner_user_id,kind,name,settings,settings_version,enabled) \ VALUES($1,$2,$3,$4,$5,1,true)", &[ &gift_component_id, &user_id, &GIFT_EFFECT_KIND, &GIFT_EFFECT_NAME, &gift_settings, ], ) .await?; let menu_component_id = Uuid::new_v4(); let menu_settings = serde_json::to_value(GiftMenuSettings::default()) .expect("GiftMenuSettings is always JSON serializable"); transaction .execute( "INSERT INTO component_instances \ (id,owner_user_id,kind,name,settings,settings_version,enabled) \ VALUES($1,$2,$3,$4,$5,1,true)", &[ &menu_component_id, &user_id, &GIFT_MENU_KIND, &GIFT_MENU_NAME, &menu_settings, ], ) .await?; transaction .execute( "DELETE FROM pending_registrations WHERE id=$1", &[®istration_id], ) .await?; insert_audit( &transaction, Some(user_id), "account.registered", "user", Some(user_id.to_string()), json!({"roomId":room_id,"role":role.as_str()}), ) .await?; transaction.commit().await?; Ok(RegistrationComplete { user: UserIdentity { id: user_id, username, room_id, role, }, session_token: session.token, session_expires_at: session.expires_at, recovery_codes, default_source_id, default_component_id, }) } pub async fn login(&self, username: &str, totp_code: &str) -> Result { let (_, username_normalized) = normalize_username(username)?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; let row = transaction .query_opt( "SELECT id,username,room_id,role,totp_secret_ciphertext,totp_secret_nonce,last_totp_step \ FROM users WHERE username_normalized=$1 AND status='active' FOR UPDATE", &[&username_normalized], ) .await? .ok_or(AuthError::InvalidCredentials)?; let user_id: Uuid = row.get(0); let username: String = row.get(1); let room_id: String = row.get(2); let role = UserRole::parse(row.get::<_, String>(3).as_str())?; let secret = self.cipher.decrypt( &EncryptedSecret { ciphertext: row.get(4), nonce: row.get(5), }, format!("user-totp:{user_id}").as_bytes(), )?; let last_step: Option = row.get(6); let accepted_step = accepted_totp_step( &secret, &self.issuer, &username, totp_code, Utc::now().timestamp(), last_step, )? .ok_or(AuthError::InvalidCredentials)?; let changed = transaction .execute( "UPDATE users SET last_totp_step=$1,updated_at=now() \ WHERE id=$2 AND (last_totp_step IS NULL OR last_totp_step<$1)", &[&accepted_step, &user_id], ) .await?; if changed != 1 { return Err(AuthError::TotpReplay); } let session = insert_session(&transaction, user_id, self.session_ttl).await?; insert_audit( &transaction, Some(user_id), "auth.login.totp", "session", Some(session.id.to_string()), json!({}), ) .await?; transaction.commit().await?; Ok(LoginResult { user: UserIdentity { id: user_id, username, room_id, role, }, session_token: session.token, session_expires_at: session.expires_at, }) } pub async fn login_with_recovery_code( &self, username: &str, recovery_code: &str, ) -> Result { let (_, username_normalized) = normalize_username(username)?; let recovery_digest = token_digest(recovery_code.trim()); let mut client = self.db.get().await?; let transaction = client.transaction().await?; let row = transaction .query_opt( "SELECT id,username,room_id,role FROM users \ WHERE username_normalized=$1 AND status='active' FOR UPDATE", &[&username_normalized], ) .await? .ok_or(AuthError::InvalidCredentials)?; let user_id: Uuid = row.get(0); let recovery = transaction .query_opt( "SELECT id FROM recovery_codes \ WHERE user_id=$1 AND code_digest=$2 AND consumed_at IS NULL FOR UPDATE", &[&user_id, &recovery_digest], ) .await? .ok_or(AuthError::InvalidCredentials)?; let recovery_id: Uuid = recovery.get(0); transaction .execute( "UPDATE recovery_codes SET consumed_at=now() WHERE id=$1", &[&recovery_id], ) .await?; let session = insert_session(&transaction, user_id, self.session_ttl).await?; insert_audit( &transaction, Some(user_id), "auth.login.recovery", "session", Some(session.id.to_string()), json!({"recoveryCodeId":recovery_id}), ) .await?; transaction.commit().await?; Ok(LoginResult { user: UserIdentity { id: user_id, username: row.get(1), room_id: row.get(2), role: UserRole::parse(row.get::<_, String>(3).as_str())?, }, session_token: session.token, session_expires_at: session.expires_at, }) } pub async fn regenerate_recovery_codes( &self, user_id: Uuid, totp_code: &str, ) -> Result, AuthError> { let mut client = self.db.get().await?; let transaction = client.transaction().await?; let row = transaction .query_opt( "SELECT username,totp_secret_ciphertext,totp_secret_nonce,last_totp_step \ FROM users WHERE id=$1 AND status='active' FOR UPDATE", &[&user_id], ) .await? .ok_or(AuthError::InvalidCredentials)?; let username: String = row.get(0); let secret = self.cipher.decrypt( &EncryptedSecret { ciphertext: row.get(1), nonce: row.get(2), }, format!("user-totp:{user_id}").as_bytes(), )?; let accepted_step = accepted_totp_step( &secret, &self.issuer, &username, totp_code, Utc::now().timestamp(), row.get(3), )? .ok_or(AuthError::InvalidCredentials)?; transaction .execute( "UPDATE users SET last_totp_step=$1,updated_at=now() WHERE id=$2", &[&accepted_step, &user_id], ) .await?; let codes = replace_recovery_codes(&transaction, user_id).await?; insert_audit( &transaction, Some(user_id), "auth.recovery_codes.regenerated", "user", Some(user_id.to_string()), json!({}), ) .await?; transaction.commit().await?; Ok(codes) } pub async fn authenticate_session( &self, raw_session_token: &str, ) -> Result { let digest = token_digest(raw_session_token.trim()); let client = self.db.get().await?; let row = client .query_opt( "SELECT s.id,s.expires_at,u.id,u.username,u.room_id,u.role \ FROM user_sessions s JOIN users u ON u.id=s.user_id \ WHERE s.token_digest=$1 AND s.revoked_at IS NULL AND s.expires_at>now() \ AND u.status='active'", &[&digest], ) .await? .ok_or(AuthError::InvalidSession)?; let session_id: Uuid = row.get(0); client .execute( "UPDATE user_sessions SET last_seen_at=now() \ WHERE id=$1 AND last_seen_at(5).as_str())?, }, }) } pub async fn revoke_session(&self, raw_session_token: &str) -> Result<(), AuthError> { let digest = token_digest(raw_session_token.trim()); let client = self.db.get().await?; client .execute( "UPDATE user_sessions SET revoked_at=COALESCE(revoked_at,now()) \ WHERE token_digest=$1", &[&digest], ) .await?; Ok(()) } pub async fn revoke_all_sessions(&self, user_id: Uuid) -> Result { let client = self.db.get().await?; Ok(client .execute( "UPDATE user_sessions SET revoked_at=now() \ WHERE user_id=$1 AND revoked_at IS NULL", &[&user_id], ) .await?) } pub async fn set_cookiecloud_credentials( &self, user_id: Uuid, credentials: &CookieCloudCredentials, ) -> Result<(), AuthError> { let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; ensure_active_user(&transaction, user_id).await?; Db::set_tenant(&transaction, user_id).await?; upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?; insert_audit( &transaction, Some(user_id), "cookiecloud.credentials.updated", "user", Some(user_id.to_string()), json!({"host":host}), ) .await?; transaction.commit().await?; Ok(()) } /// Atomically changes the account's current room and encrypted CookieCloud /// source. The live source keeps its stable ID, allowing the supervisor to /// cancel the old room connection and start the replacement without /// changing any component or OBS token. pub async fn set_account_live_source( &self, user_id: Uuid, room_id: &str, credentials: &CookieCloudCredentials, ) -> Result<(), AuthError> { let room_id = room_id.trim(); validate_room_id(room_id)?; let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; ensure_active_user(&transaction, user_id).await?; let old_room_id: String = transaction .query_one("SELECT room_id FROM users WHERE id=$1", &[&user_id]) .await? .get(0); let room_in_use: bool = transaction .query_one( "SELECT EXISTS(SELECT 1 FROM users WHERE room_id=$1 AND id<>$2)", &[&room_id, &user_id], ) .await? .get(0); if room_in_use { return Err(AuthError::RoomUnavailable); } Db::set_tenant(&transaction, user_id).await?; transaction .execute( "UPDATE users SET room_id=$1,updated_at=now() WHERE id=$2 AND status='active'", &[&room_id, &user_id], ) .await .map_err(map_room_write_error)?; let changed = transaction .execute( "UPDATE live_sources SET room_id=$1,updated_at=now() \ WHERE owner_user_id=$2", &[&room_id, &user_id], ) .await .map_err(map_room_write_error)?; if changed != 1 { return Err(AuthError::DatabaseInvariant( "account must own exactly one live source", )); } upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?; insert_audit( &transaction, Some(user_id), "account.live_source.updated", "user", Some(user_id.to_string()), json!({ "host": host, "oldRoomId": old_room_id, "newRoomId": room_id, "roomChanged": old_room_id != room_id, }), ) .await?; transaction.commit().await?; Ok(()) } fn encrypt_cookiecloud_credentials( &self, user_id: Uuid, credentials: &CookieCloudCredentials, ) -> Result<(String, EncryptedSecret), AuthError> { let host = normalize_cookiecloud_host(&credentials.host) .map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?; if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() { return Err(AuthError::InvalidInput( "CookieCloud key and password cannot be empty", )); } let plaintext = serde_json::to_vec(&CookieCloudSecrets { key: credentials.secrets.key.trim().to_owned(), password: credentials.secrets.password.clone(), }) .map_err(|error| AuthError::Crypto(error.to_string()))?; let encrypted = self .cipher .encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?; Ok((host, encrypted)) } pub async fn get_cookiecloud_credentials( &self, user_id: Uuid, ) -> Result, AuthError> { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, user_id).await?; let row = transaction .query_opt( "SELECT host,secrets_ciphertext,secrets_nonce \ FROM cookiecloud_credentials WHERE user_id=$1", &[&user_id], ) .await?; let Some(row) = row else { transaction.commit().await?; return Ok(None); }; let host: String = row.get(0); let plaintext = self.cipher.decrypt( &EncryptedSecret { ciphertext: row.get(1), nonce: row.get(2), }, format!("cookiecloud:{user_id}").as_bytes(), )?; let secrets: CookieCloudSecrets = serde_json::from_slice(&plaintext) .map_err(|error| AuthError::Crypto(error.to_string()))?; transaction.commit().await?; Ok(Some(CookieCloudCredentials { host, secrets })) } pub async fn issue_component_access_token( &self, owner_user_id: Uuid, component_instance_id: Uuid, label: &str, scopes: &[String], valid_for: Option, ) -> Result { let raw_token = random_token("component", 32); self.insert_component_access_token( owner_user_id, component_instance_id, label, scopes, valid_for, raw_token, ) .await } /// Replaces every active token for one component as a single tenant-scoped /// database operation. The caller should close existing component streams /// only after this returns successfully, so an insert failure rolls the /// revocations back and leaves the previous token usable. pub async fn rotate_component_access_token( &self, owner_user_id: Uuid, component_instance_id: Uuid, label: &str, scopes: &[String], valid_for: Option, ) -> Result { let label = validate_component_token_request(label, scopes)?; let raw_token = random_token("component", 32); let digest = token_digest(&raw_token); let id = Uuid::new_v4(); let expires_at = valid_for.map(future_time).transpose()?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_user_id).await?; require_enabled_component(&transaction, owner_user_id, component_instance_id).await?; let revoked_count = transaction .execute( "UPDATE component_access_tokens SET revoked_at=now() \ WHERE owner_user_id=$1 AND component_instance_id=$2 AND revoked_at IS NULL", &[&owner_user_id, &component_instance_id], ) .await?; insert_component_token_row( &transaction, id, owner_user_id, component_instance_id, label, scopes, &raw_token, &digest, expires_at, ) .await?; insert_audit( &transaction, Some(owner_user_id), "component.token.rotated", "component_access_token", Some(id.to_string()), json!({ "componentId":component_instance_id, "scopes":scopes, "revokedCount":revoked_count, }), ) .await?; transaction.commit().await?; Ok(CreatedComponentToken { id, token: raw_token, component_instance_id, expires_at, }) } /// Used once when importing the legacy TOML OBS token. The raw value is /// digested immediately and is never returned by later lookup APIs. pub async fn import_component_access_token( &self, owner_user_id: Uuid, component_instance_id: Uuid, label: &str, scopes: &[String], raw_token: String, ) -> Result { self.insert_component_access_token( owner_user_id, component_instance_id, label, scopes, None, raw_token, ) .await } async fn insert_component_access_token( &self, owner_user_id: Uuid, component_instance_id: Uuid, label: &str, scopes: &[String], valid_for: Option, raw_token: String, ) -> Result { let label = validate_component_token_request(label, scopes)?; let digest = token_digest(&raw_token); let id = Uuid::new_v4(); let expires_at = valid_for.map(future_time).transpose()?; let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_user_id).await?; require_enabled_component(&transaction, owner_user_id, component_instance_id).await?; if let Some(existing) = transaction .query_opt( "SELECT id FROM component_access_tokens \ WHERE token_digest=$1 AND owner_user_id=$2", &[&digest, &owner_user_id], ) .await? { return Ok(CreatedComponentToken { id: existing.get(0), token: raw_token, component_instance_id, expires_at, }); } insert_component_token_row( &transaction, id, owner_user_id, component_instance_id, label, scopes, &raw_token, &digest, expires_at, ) .await?; insert_audit( &transaction, Some(owner_user_id), "component.token.created", "component_access_token", Some(id.to_string()), json!({"componentId":component_instance_id,"scopes":scopes}), ) .await?; transaction.commit().await?; Ok(CreatedComponentToken { id, token: raw_token, component_instance_id, expires_at, }) } pub async fn authenticate_component_access_token( &self, raw_token: &str, ) -> Result { let digest = token_digest(raw_token.trim()); let mut client = self.db.get().await?; let row = client .query_opt( "SELECT token_id,owner_user_id,component_instance_id,scopes \ FROM lookup_component_access_token($1)", &[&digest], ) .await? .ok_or(AuthError::InvalidComponentToken)?; let identity = ComponentTokenIdentity { token_id: row.get(0), owner_user_id: row.get(1), component_instance_id: row.get(2), scopes: row.get(3), }; let transaction = client.transaction().await?; Db::set_tenant(&transaction, identity.owner_user_id).await?; transaction .execute( "UPDATE component_access_tokens SET last_used_at=now() WHERE id=$1", &[&identity.token_id], ) .await?; transaction.commit().await?; Ok(identity) } pub async fn revoke_component_access_token( &self, owner_user_id: Uuid, token_id: Uuid, ) -> Result<(), AuthError> { let mut client = self.db.get().await?; let transaction = client.transaction().await?; Db::set_tenant(&transaction, owner_user_id).await?; let changed = transaction .execute( "UPDATE component_access_tokens SET revoked_at=now() \ WHERE id=$1 AND owner_user_id=$2 AND revoked_at IS NULL", &[&token_id, &owner_user_id], ) .await?; if changed == 0 { return Err(AuthError::ComponentUnavailable); } insert_audit( &transaction, Some(owner_user_id), "component.token.revoked", "component_access_token", Some(token_id.to_string()), json!({}), ) .await?; transaction.commit().await?; Ok(()) } /// Imports the legacy room-keyed overlay JSON into the account's default /// component. It is idempotent and always runs in the correct RLS context. pub async fn import_legacy_overlay_settings( &self, owner_user_id: Uuid, settings: Value, ) -> Result { if !settings.is_object() { return Err(AuthError::InvalidInput( "component settings must be an object", )); } let mut client = self.db.get().await?; let transaction = client.transaction().await?; let room_id: String = transaction .query_opt( "SELECT room_id FROM users WHERE id=$1 AND status='active'", &[&owner_user_id], ) .await? .ok_or(AuthError::InvalidCredentials)? .get(0); Db::set_tenant(&transaction, owner_user_id).await?; let component_id = if let Some(row) = transaction .query_opt( "SELECT id FROM component_instances \ WHERE owner_user_id=$1 AND kind='danmaku_overlay' ORDER BY created_at LIMIT 1", &[&owner_user_id], ) .await? { let id: Uuid = row.get(0); transaction .execute( "UPDATE component_instances SET settings=$1,settings_version=1,\ updated_at=now() WHERE id=$2", &[&settings, &id], ) .await?; id } else { let id = Uuid::new_v4(); transaction .execute( "INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \ VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)", &[&id, &owner_user_id, &settings], ) .await?; id }; transaction .execute( "UPDATE overlay_settings SET owner_user_id=$1,component_instance_id=$2 \ WHERE room_id=$3", &[&owner_user_id, &component_id, &room_id], ) .await?; transaction.commit().await?; Ok(component_id) } } async fn upsert_cookiecloud_credentials( transaction: &Transaction<'_>, user_id: Uuid, host: &str, encrypted: &EncryptedSecret, ) -> Result<(), AuthError> { transaction .execute( "INSERT INTO cookiecloud_credentials \ (user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \ ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\ secrets_ciphertext=EXCLUDED.secrets_ciphertext,\ secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()", &[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce], ) .await?; Ok(()) } fn map_room_write_error(error: tokio_postgres::Error) -> AuthError { if error.code() == Some(&SqlState::UNIQUE_VIOLATION) { AuthError::RoomUnavailable } else { error.into() } } #[derive(Clone)] struct SecretCipher { cipher: Arc, } impl SecretCipher { fn new(key: [u8; 32]) -> Self { Self { cipher: Arc::new(XChaCha20Poly1305::new(Key::from_slice(&key))), } } fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result { let mut nonce = [0_u8; 24]; rand::rng().fill_bytes(&mut nonce); let ciphertext = self .cipher .encrypt( XNonce::from_slice(&nonce), Payload { msg: plaintext, aad, }, ) .map_err(|_| AuthError::Crypto("secret encryption failed".into()))?; Ok(EncryptedSecret { ciphertext, nonce: nonce.to_vec(), }) } fn decrypt(&self, encrypted: &EncryptedSecret, aad: &[u8]) -> Result, AuthError> { if encrypted.nonce.len() != 24 { return Err(AuthError::Crypto("invalid secret nonce".into())); } self.cipher .decrypt( XNonce::from_slice(&encrypted.nonce), Payload { msg: &encrypted.ciphertext, aad, }, ) .map_err(|_| AuthError::Crypto("secret authentication failed".into())) } } struct EncryptedSecret { ciphertext: Vec, nonce: Vec, } fn build_totp(secret: &[u8], issuer: &str, account: &str) -> Result { TOTP::new( Algorithm::SHA1, TOTP_DIGITS, 0, TOTP_PERIOD_SECONDS, secret.to_vec(), Some(issuer.to_owned()), account.to_owned(), ) .map_err(|error| AuthError::Totp(error.to_string())) } fn accepted_totp_step( secret: &[u8], issuer: &str, account: &str, code: &str, unix_seconds: i64, last_accepted_step: Option, ) -> Result, AuthError> { let code = code.trim(); if code.len() != TOTP_DIGITS || !code.bytes().all(|byte| byte.is_ascii_digit()) { return Ok(None); } let totp = build_totp(secret, issuer, account)?; let current_step = unix_seconds.max(0) / TOTP_PERIOD_SECONDS as i64; for offset in [0_i64, -1, 1] { let step = current_step + offset; if step < 0 || last_accepted_step.is_some_and(|last| step <= last) { continue; } let expected = totp.generate((step as u64) * TOTP_PERIOD_SECONDS); if bool::from(expected.as_bytes().ct_eq(code.as_bytes())) { return Ok(Some(step)); } } Ok(None) } async fn require_system_admin( transaction: &Transaction<'_>, user_id: Uuid, ) -> Result<(), AuthError> { let row = transaction .query_opt( "SELECT role,status FROM users WHERE id=$1 FOR UPDATE", &[&user_id], ) .await? .ok_or(AuthError::Forbidden)?; if row.get::<_, String>(0) != "system_admin" || row.get::<_, String>(1) != "active" { return Err(AuthError::Forbidden); } Ok(()) } async fn ensure_active_user(transaction: &Transaction<'_>, user_id: Uuid) -> Result<(), AuthError> { if !transaction .query_one( "SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND status='active')", &[&user_id], ) .await? .get::<_, bool>(0) { return Err(AuthError::InvalidCredentials); } Ok(()) } fn validate_component_token_request<'a>( label: &'a str, scopes: &[String], ) -> Result<&'a str, AuthError> { let label = label.trim(); if label.is_empty() || scopes.is_empty() || scopes.iter().any(|scope| scope.trim().is_empty()) { return Err(AuthError::InvalidInput( "component token label and scopes cannot be empty", )); } Ok(label) } async fn require_enabled_component( transaction: &Transaction<'_>, owner_user_id: Uuid, component_instance_id: Uuid, ) -> Result<(), AuthError> { if transaction .query_opt( "SELECT 1 FROM component_instances \ WHERE id=$1 AND owner_user_id=$2 AND enabled", &[&component_instance_id, &owner_user_id], ) .await? .is_none() { return Err(AuthError::ComponentUnavailable); } Ok(()) } #[allow(clippy::too_many_arguments)] async fn insert_component_token_row( transaction: &Transaction<'_>, id: Uuid, owner_user_id: Uuid, component_instance_id: Uuid, label: &str, scopes: &[String], raw_token: &str, digest: &[u8], expires_at: Option>, ) -> Result<(), AuthError> { transaction .execute( "INSERT INTO component_access_tokens \ (id,owner_user_id,component_instance_id,label,token_prefix,token_digest,scopes,expires_at) \ VALUES($1,$2,$3,$4,$5,$6,$7,$8)", &[ &id, &owner_user_id, &component_instance_id, &label, &token_prefix(raw_token), &digest, &scopes, &expires_at, ], ) .await?; Ok(()) } async fn insert_session( transaction: &Transaction<'_>, user_id: Uuid, ttl: Duration, ) -> Result { let id = Uuid::new_v4(); let token = random_token("session", 32); let digest = token_digest(&token); let expires_at = future_time(ttl)?; transaction .execute( "INSERT INTO user_sessions(id,user_id,token_digest,expires_at) VALUES($1,$2,$3,$4)", &[&id, &user_id, &digest, &expires_at], ) .await?; Ok(NewSession { id, token, expires_at, }) } async fn replace_recovery_codes( transaction: &Transaction<'_>, user_id: Uuid, ) -> Result, AuthError> { transaction .execute("DELETE FROM recovery_codes WHERE user_id=$1", &[&user_id]) .await?; let mut codes = Vec::with_capacity(RECOVERY_CODE_COUNT); for _ in 0..RECOVERY_CODE_COUNT { let code = random_token("recovery", 20); transaction .execute( "INSERT INTO recovery_codes(id,user_id,code_digest) VALUES($1,$2,$3)", &[&Uuid::new_v4(), &user_id, &token_digest(&code)], ) .await?; codes.push(code); } Ok(codes) } async fn insert_audit( transaction: &Transaction<'_>, actor_user_id: Option, action: &str, target_type: &str, target_id: Option, metadata: Value, ) -> Result<(), AuthError> { transaction .execute( "INSERT INTO audit_log(actor_user_id,action,target_type,target_id,metadata) \ VALUES($1,$2,$3,$4,$5)", &[&actor_user_id, &action, &target_type, &target_id, &metadata], ) .await?; Ok(()) } fn normalize_username(username: &str) -> Result<(String, String), AuthError> { let username = username.trim(); let length = username.chars().count(); if !(3..=32).contains(&length) || !username .chars() .all(|value| value.is_alphanumeric() || matches!(value, '_' | '-' | '.')) { return Err(AuthError::InvalidInput( "username must be 3-32 letters, numbers, dots, dashes or underscores", )); } Ok((username.to_owned(), username.to_lowercase())) } fn validate_room_id(room_id: &str) -> Result<(), AuthError> { if room_id.is_empty() || room_id.starts_with('0') || room_id.len() > 20 || !room_id.bytes().all(|value| value.is_ascii_digit()) { return Err(AuthError::InvalidInput( "room_id must be a positive decimal Bilibili room ID", )); } Ok(()) } fn random_token(prefix: &str, random_bytes: usize) -> String { let mut bytes = vec![0_u8; random_bytes]; rand::rng().fill_bytes(&mut bytes); format!("{prefix}_{}", URL_SAFE_NO_PAD.encode(bytes)) } fn token_digest(token: &str) -> Vec { Sha256::digest(token.as_bytes()).to_vec() } fn token_prefix(token: &str) -> String { token.chars().take(14).collect() } fn future_time(duration: Duration) -> Result, AuthError> { let duration = ChronoDuration::from_std(duration) .map_err(|_| AuthError::InvalidInput("duration is too large"))?; Utc::now() .checked_add_signed(duration) .ok_or(AuthError::InvalidInput("duration is too large")) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UserRole { SystemAdmin, User, } impl UserRole { pub fn as_str(self) -> &'static str { match self { Self::SystemAdmin => "system_admin", Self::User => "user", } } fn parse(value: &str) -> Result { match value { "system_admin" => Ok(Self::SystemAdmin), "user" => Ok(Self::User), _ => Err(AuthError::DatabaseInvariant("unknown user role")), } } } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UserIdentity { pub id: Uuid, pub username: String, pub room_id: String, pub role: UserRole, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreatedInvitation { pub id: Uuid, pub code: String, pub room_id: String, pub expires_at: DateTime, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum InvitationState { Available, Consumed, Expired, Revoked, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct InvitationSummary { pub id: Uuid, pub code_prefix: String, pub room_id: String, pub created_at: DateTime, pub expires_at: DateTime, pub state: InvitationState, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct RegistrationStart { pub enrollment_token: String, pub username: String, pub room_id: String, pub secret: String, pub otpauth_uri: String, pub qr_data_url: String, pub expires_at: DateTime, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct RegistrationComplete { pub user: UserIdentity, pub session_token: String, pub session_expires_at: DateTime, pub recovery_codes: Vec, pub default_source_id: Uuid, pub default_component_id: Uuid, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct LoginResult { pub user: UserIdentity, pub session_token: String, pub session_expires_at: DateTime, } #[derive(Clone, Debug)] pub struct SessionIdentity { pub session_id: Uuid, pub expires_at: DateTime, pub user: UserIdentity, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreatedComponentToken { pub id: Uuid, pub token: String, pub component_instance_id: Uuid, pub expires_at: Option>, } #[derive(Clone, Debug)] pub struct ComponentTokenIdentity { pub token_id: Uuid, pub owner_user_id: Uuid, pub component_instance_id: Uuid, pub scopes: Vec, } struct NewSession { id: Uuid, token: String, expires_at: DateTime, } #[derive(Debug)] pub enum AuthError { InvalidInput(&'static str), InvalidMasterKey, BootstrapAlreadyCompleted, Forbidden, InvitationUnavailable, EnrollmentUnavailable, AccountUnavailable, RoomUnavailable, InvalidTotp, TotpReplay, InvalidCredentials, InvalidSession, InvalidComponentToken, ComponentUnavailable, Totp(String), Crypto(String), DatabaseInvariant(&'static str), Database(DbError), } impl fmt::Display for AuthError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidInput(message) => write!(formatter, "invalid input: {message}"), Self::InvalidMasterKey => { write!(formatter, "master key must decode to exactly 32 bytes") } Self::BootstrapAlreadyCompleted => { write!(formatter, "system administrator already exists") } Self::Forbidden => write!(formatter, "operation is not permitted"), Self::InvitationUnavailable => { write!(formatter, "invitation is invalid or unavailable") } Self::EnrollmentUnavailable => write!( formatter, "registration enrollment is invalid or unavailable" ), Self::AccountUnavailable => write!(formatter, "username or room is unavailable"), Self::RoomUnavailable => write!(formatter, "room is already assigned"), Self::InvalidTotp => write!(formatter, "invalid TOTP code"), Self::TotpReplay => write!(formatter, "TOTP code was already used"), Self::InvalidCredentials => write!(formatter, "invalid credentials"), Self::InvalidSession => write!(formatter, "invalid or expired session"), Self::InvalidComponentToken => write!(formatter, "invalid or expired component token"), Self::ComponentUnavailable => write!(formatter, "component is unavailable"), Self::Totp(message) => write!(formatter, "TOTP: {message}"), Self::Crypto(message) => write!(formatter, "cryptography: {message}"), Self::DatabaseInvariant(message) => write!(formatter, "database invariant: {message}"), Self::Database(error) => error.fmt(formatter), } } } impl std::error::Error for AuthError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Database(error) => Some(error), _ => None, } } } impl From for AuthError { fn from(error: DbError) -> Self { Self::Database(error) } } impl From for AuthError { fn from(error: tokio_postgres::Error) -> Self { Self::Database(DbError::Postgres(error)) } } #[cfg(test)] mod tests { use super::*; #[test] fn xchacha_round_trip_authenticates_aad() { let cipher = SecretCipher::new([7_u8; 32]); let encrypted = cipher .encrypt(b"very secret", b"tenant:a") .expect("encrypt"); assert_eq!( cipher.decrypt(&encrypted, b"tenant:a").expect("decrypt"), b"very secret" ); assert!(cipher.decrypt(&encrypted, b"tenant:b").is_err()); assert_eq!(encrypted.nonce.len(), 24); } #[test] fn token_material_is_random_and_digest_only_is_fixed_width() { let first = random_token("session", 32); let second = random_token("session", 32); assert_ne!(first, second); assert_eq!(token_digest(&first).len(), 32); assert!(!String::from_utf8_lossy(&token_digest(&first)).contains(&first)); } #[test] fn totp_accepts_one_step_of_skew_and_rejects_replay() { let secret = b"12345678901234567890"; let now = 1_700_000_000_i64; let totp = build_totp(secret, "Test Issuer", "viewer").expect("totp"); let current_step = now / TOTP_PERIOD_SECONDS as i64; let previous = totp.generate(((current_step - 1) as u64) * TOTP_PERIOD_SECONDS); let accepted = accepted_totp_step(secret, "Test Issuer", "viewer", &previous, now, None) .expect("validate") .expect("accepted"); assert_eq!(accepted, current_step - 1); assert_eq!( accepted_totp_step( secret, "Test Issuer", "viewer", &previous, now, Some(accepted), ) .expect("validate"), None ); } #[test] fn username_and_room_validation_are_canonical() { assert_eq!( normalize_username(" ExampleStreamer ").expect("username"), ("ExampleStreamer".into(), "examplestreamer".into()) ); assert!(normalize_username("ab").is_err()); assert!(normalize_username("bad name").is_err()); assert!(validate_room_id("12345").is_ok()); assert!(validate_room_id("0123").is_err()); } }