Files
lxc-streamutils/apps/server-rust/src/auth.rs
T

1983 lines
66 KiB
Rust

//! 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<str>,
session_ttl: Duration,
enrollment_ttl: Duration,
}
impl AuthService {
pub fn new(
db: Db,
master_key: [u8; 32],
issuer: impl Into<String>,
session_ttl: Duration,
enrollment_ttl: Duration,
) -> Result<Self, AuthError> {
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<String>,
session_ttl: Duration,
enrollment_ttl: Duration,
) -> Result<Self, AuthError> {
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<bool, AuthError> {
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<CreatedInvitation, AuthError> {
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<CreatedInvitation, AuthError> {
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<Vec<InvitationSummary>, 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<Utc> = row.get(4);
let consumed_at: Option<DateTime<Utc>> = row.get(5);
let revoked_at: Option<DateTime<Utc>> = 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<RegistrationStart, AuthError> {
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<Utc> = invitation.get(2);
let consumed_at: Option<DateTime<Utc>> = invitation.get(3);
let revoked_at: Option<DateTime<Utc>> = 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)",
&[
&registration_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<RegistrationComplete, AuthError> {
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<u8> = row.get(5);
let nonce: Vec<u8> = row.get(6);
let enrollment_expires: DateTime<Utc> = row.get(7);
let failed_attempts: i32 = row.get(8);
let role = UserRole::parse(row.get::<_, String>(9).as_str())?;
let invitation_expires: DateTime<Utc> = row.get(10);
let invitation_consumed: Option<DateTime<Utc>> = row.get(11);
let invitation_revoked: Option<DateTime<Utc>> = 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",
&[&registration_id],
)
.await?;
} else {
transaction
.execute(
"UPDATE pending_registrations SET failed_attempts=failed_attempts+1 WHERE id=$1",
&[&registration_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",
&[&registration_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<LoginResult, AuthError> {
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<i64> = 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<LoginResult, AuthError> {
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<Vec<String>, 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<SessionIdentity, AuthError> {
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<now()-interval '5 minutes'",
&[&session_id],
)
.await?;
Ok(SessionIdentity {
session_id,
expires_at: row.get(1),
user: UserIdentity {
id: row.get(2),
username: row.get(3),
room_id: row.get(4),
role: UserRole::parse(row.get::<_, String>(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<u64, AuthError> {
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<Option<CookieCloudCredentials>, 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<Duration>,
) -> Result<CreatedComponentToken, AuthError> {
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<Duration>,
) -> Result<CreatedComponentToken, AuthError> {
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<CreatedComponentToken, AuthError> {
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<Duration>,
raw_token: String,
) -> Result<CreatedComponentToken, AuthError> {
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<ComponentTokenIdentity, AuthError> {
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<Uuid, AuthError> {
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<XChaCha20Poly1305>,
}
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<EncryptedSecret, AuthError> {
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<Vec<u8>, 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<u8>,
nonce: Vec<u8>,
}
fn build_totp(secret: &[u8], issuer: &str, account: &str) -> Result<TOTP, AuthError> {
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<i64>,
) -> Result<Option<i64>, 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<DateTime<Utc>>,
) -> 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<NewSession, AuthError> {
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<Vec<String>, 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<Uuid>,
action: &str,
target_type: &str,
target_id: Option<String>,
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<u8> {
Sha256::digest(token.as_bytes()).to_vec()
}
fn token_prefix(token: &str) -> String {
token.chars().take(14).collect()
}
fn future_time(duration: Duration) -> Result<DateTime<Utc>, 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<Self, AuthError> {
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<Utc>,
}
#[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<Utc>,
pub expires_at: DateTime<Utc>,
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<Utc>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RegistrationComplete {
pub user: UserIdentity,
pub session_token: String,
pub session_expires_at: DateTime<Utc>,
pub recovery_codes: Vec<String>,
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<Utc>,
}
#[derive(Clone, Debug)]
pub struct SessionIdentity {
pub session_id: Uuid,
pub expires_at: DateTime<Utc>,
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<DateTime<Utc>>,
}
#[derive(Clone, Debug)]
pub struct ComponentTokenIdentity {
pub token_id: Uuid,
pub owner_user_id: Uuid,
pub component_instance_id: Uuid,
pub scopes: Vec<String>,
}
struct NewSession {
id: Uuid,
token: String,
expires_at: DateTime<Utc>,
}
#[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<DbError> for AuthError {
fn from(error: DbError) -> Self {
Self::Database(error)
}
}
impl From<tokio_postgres::Error> 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());
}
}