update UI
This commit is contained in:
@@ -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, ¤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,
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user