update UI

This commit is contained in:
2026-08-11 09:19:13 -07:00
parent 97a3f1be48
commit 4eda93cf24
31 changed files with 952 additions and 95 deletions
+275
View File
@@ -891,6 +891,251 @@ impl AuthService {
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, &current_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,
@@ -1510,6 +1755,11 @@ fn accepted_totp_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,
@@ -1790,6 +2040,18 @@ pub struct RegistrationStart {
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 {
@@ -1847,6 +2109,7 @@ pub enum AuthError {
Forbidden,
InvitationUnavailable,
EnrollmentUnavailable,
TotpResetUnavailable,
AccountUnavailable,
RoomUnavailable,
InvalidTotp,
@@ -1879,6 +2142,9 @@ impl fmt::Display for AuthError {
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"),
@@ -1968,6 +2234,15 @@ mod tests {
);
}
#[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!(