2276 lines
77 KiB
Rust
2276 lines
77 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},
|
|
guard_effect::{GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings},
|
|
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)",
|
|
&[
|
|
®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<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",
|
|
&[®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 guard_component_id = Uuid::new_v4();
|
|
let guard_settings = serde_json::to_value(GuardEffectSettings::default())
|
|
.expect("GuardEffectSettings 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)",
|
|
&[
|
|
&guard_component_id,
|
|
&user_id,
|
|
&GUARD_EFFECT_KIND,
|
|
&GUARD_EFFECT_NAME,
|
|
&guard_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<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)
|
|
}
|
|
|
|
/// Begin replacement of the current account's TOTP secret after explicit
|
|
/// step-up authentication. The existing secret remains valid until
|
|
/// `totp_reset_confirm` commits the replacement.
|
|
pub async fn totp_reset_start(
|
|
&self,
|
|
user_id: Uuid,
|
|
current_code: &str,
|
|
) -> Result<TotpResetStart, AuthError> {
|
|
let mut client = self.db.get().await?;
|
|
let transaction = client.transaction().await?;
|
|
let row = transaction
|
|
.query_opt(
|
|
"SELECT username,room_id,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 room_id: String = row.get(1);
|
|
let proof_kind = if is_totp_code(current_code) {
|
|
let secret = self.cipher.decrypt(
|
|
&EncryptedSecret {
|
|
ciphertext: row.get(2),
|
|
nonce: row.get(3),
|
|
},
|
|
format!("user-totp:{user_id}").as_bytes(),
|
|
)?;
|
|
let accepted_step = accepted_totp_step(
|
|
&secret,
|
|
&self.issuer,
|
|
&username,
|
|
current_code,
|
|
Utc::now().timestamp(),
|
|
row.get(4),
|
|
)?
|
|
.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);
|
|
}
|
|
"totp"
|
|
} else {
|
|
let recovery_digest = token_digest(current_code.trim());
|
|
let recovery_id = 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)?
|
|
.get::<_, Uuid>(0);
|
|
transaction
|
|
.execute(
|
|
"UPDATE recovery_codes SET consumed_at=now() WHERE id=$1",
|
|
&[&recovery_id],
|
|
)
|
|
.await?;
|
|
"recovery"
|
|
};
|
|
|
|
let reset_id = Uuid::new_v4();
|
|
let enrollment_token = random_token("totp-reset", 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-reset:{reset_id}").as_bytes(),
|
|
)?;
|
|
let expires_at = future_time(self.enrollment_ttl)?;
|
|
|
|
Db::set_tenant(&transaction, user_id).await?;
|
|
transaction
|
|
.execute(
|
|
"DELETE FROM pending_totp_resets WHERE user_id=$1",
|
|
&[&user_id],
|
|
)
|
|
.await?;
|
|
transaction
|
|
.execute(
|
|
"INSERT INTO pending_totp_resets \
|
|
(id,user_id,enrollment_token_digest,totp_secret_ciphertext,totp_secret_nonce,expires_at) \
|
|
VALUES($1,$2,$3,$4,$5,$6)",
|
|
&[
|
|
&reset_id,
|
|
&user_id,
|
|
&enrollment_digest,
|
|
&encrypted.ciphertext,
|
|
&encrypted.nonce,
|
|
&expires_at,
|
|
],
|
|
)
|
|
.await?;
|
|
insert_audit(
|
|
&transaction,
|
|
Some(user_id),
|
|
"auth.totp_reset.started",
|
|
"user",
|
|
Some(user_id.to_string()),
|
|
json!({"proof":proof_kind}),
|
|
)
|
|
.await?;
|
|
transaction.commit().await?;
|
|
Ok(TotpResetStart {
|
|
enrollment_token,
|
|
username,
|
|
room_id,
|
|
secret: encoded_secret,
|
|
otpauth_uri,
|
|
qr_data_url,
|
|
expires_at,
|
|
})
|
|
}
|
|
|
|
/// Confirm a pending replacement, rotate recovery codes, and revoke every
|
|
/// other browser session atomically. The session performing the reset is
|
|
/// retained so it can display the one-time recovery codes.
|
|
pub async fn totp_reset_confirm(
|
|
&self,
|
|
user_id: Uuid,
|
|
current_session_id: Uuid,
|
|
enrollment_token: &str,
|
|
new_totp_code: &str,
|
|
) -> Result<Vec<String>, AuthError> {
|
|
let digest = token_digest(enrollment_token.trim());
|
|
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 r.id,r.totp_secret_ciphertext,r.totp_secret_nonce,r.expires_at,\
|
|
r.failed_attempts,u.username \
|
|
FROM pending_totp_resets r JOIN users u ON u.id=r.user_id \
|
|
WHERE r.user_id=$1 AND r.enrollment_token_digest=$2 FOR UPDATE OF r,u",
|
|
&[&user_id, &digest],
|
|
)
|
|
.await?
|
|
.ok_or(AuthError::TotpResetUnavailable)?;
|
|
let reset_id: Uuid = row.get(0);
|
|
let expires_at: DateTime<Utc> = row.get(3);
|
|
let failed_attempts: i32 = row.get(4);
|
|
if expires_at <= Utc::now() {
|
|
return Err(AuthError::TotpResetUnavailable);
|
|
}
|
|
let username: String = row.get(5);
|
|
let secret = self.cipher.decrypt(
|
|
&EncryptedSecret {
|
|
ciphertext: row.get(1),
|
|
nonce: row.get(2),
|
|
},
|
|
format!("pending-totp-reset:{reset_id}").as_bytes(),
|
|
)?;
|
|
let accepted_step = accepted_totp_step(
|
|
&secret,
|
|
&self.issuer,
|
|
&username,
|
|
new_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_totp_resets WHERE id=$1", &[&reset_id])
|
|
.await?;
|
|
} else {
|
|
transaction
|
|
.execute(
|
|
"UPDATE pending_totp_resets SET failed_attempts=failed_attempts+1 WHERE id=$1",
|
|
&[&reset_id],
|
|
)
|
|
.await?;
|
|
}
|
|
transaction.commit().await?;
|
|
return Err(AuthError::InvalidTotp);
|
|
};
|
|
|
|
let encrypted = self
|
|
.cipher
|
|
.encrypt(&secret, format!("user-totp:{user_id}").as_bytes())?;
|
|
let updated = transaction
|
|
.execute(
|
|
"UPDATE users SET totp_secret_ciphertext=$1,totp_secret_nonce=$2,\
|
|
last_totp_step=$3,totp_enrolled_at=now(),auth_version=auth_version+1,updated_at=now() \
|
|
WHERE id=$4 AND status='active'",
|
|
&[
|
|
&encrypted.ciphertext,
|
|
&encrypted.nonce,
|
|
&accepted_step,
|
|
&user_id,
|
|
],
|
|
)
|
|
.await?;
|
|
if updated != 1 {
|
|
return Err(AuthError::TotpResetUnavailable);
|
|
}
|
|
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
|
|
let revoked_sessions = transaction
|
|
.execute(
|
|
"UPDATE user_sessions SET revoked_at=now() \
|
|
WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL",
|
|
&[&user_id, ¤t_session_id],
|
|
)
|
|
.await?;
|
|
transaction
|
|
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
|
|
.await?;
|
|
insert_audit(
|
|
&transaction,
|
|
Some(user_id),
|
|
"auth.totp_reset.completed",
|
|
"user",
|
|
Some(user_id.to_string()),
|
|
json!({"revokedSessions":revoked_sessions}),
|
|
)
|
|
.await?;
|
|
transaction.commit().await?;
|
|
Ok(recovery_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)
|
|
}
|
|
|
|
fn is_totp_code(code: &str) -> bool {
|
|
let code = code.trim();
|
|
code.len() == TOTP_DIGITS && code.bytes().all(|byte| byte.is_ascii_digit())
|
|
}
|
|
|
|
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 TotpResetStart {
|
|
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,
|
|
TotpResetUnavailable,
|
|
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::TotpResetUnavailable => {
|
|
write!(formatter, "TOTP reset 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 step_up_proof_only_classifies_exact_six_digit_totp_codes() {
|
|
assert!(is_totp_code(" 012345 "));
|
|
assert!(!is_totp_code("12345"));
|
|
assert!(!is_totp_code("1234567"));
|
|
assert!(!is_totp_code("12a456"));
|
|
assert!(!is_totp_code("recovery-example"));
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|