use super::*;
use alibi_core::error::codes;
#[expect(
clippy::too_many_lines,
reason = "Keep factor enrollment, backup generation, and session replacement in their callback order"
)]
pub(in crate::two_factor) async fn enable_core(
body: &EnableTwoFactorInput,
user: &impl AuthUser,
current_session: &impl AuthSession,
config: &TwoFactorConfig,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> Result<(TwoFactorEnabled, Vec<String>), BackupOperationError> {
verify_user_password(
ctx,
user,
body.password.as_deref(),
config.allow_passwordless,
)
.await?;
if body.method == TwoFactorMethod::Otp {
if config.send_otp.is_none() {
return Err(AuthError::Upstream {
status: 400,
code: codes::OTP_NOT_CONFIGURED,
message: "OTP is not available",
}
.into());
}
let updated_user = ctx
.database
.update_user_record(
user.id().as_ref(),
UpdateUser {
two_factor_enabled: Some(true),
..Default::default()
},
)
.await?;
let issued = issue_user_session_with_overrides_record(
ctx,
updated_user.id().as_ref(),
current_session.ip_address().map(str::to_owned),
current_session.user_agent().map(str::to_owned),
current_session,
)
.await
.map_err(SessionIssueError::into_auth_error)?;
ctx.database.delete_session(current_session.token()).await?;
return Ok((
TwoFactorEnabled::Otp,
vec![create_session_cookie(issued.session.token(), &ctx.config)?],
));
}
if config.totp_disabled {
return Err(AuthError::Upstream {
status: 400,
code: codes::TOTP_NOT_CONFIGURED,
message: "TOTP is not available",
}
.into());
}
let existing = ctx
.database
.get_two_factor_by_user_id(user.id().as_ref())
.await?;
if existing
.as_ref()
.is_some_and(|factor| factor.verified() != Some(false))
{
return Err(AuthError::Upstream {
status: 400,
code: codes::TOTP_ALREADY_ENABLED,
message: "TOTP is already enabled",
}
.into());
}
let secret = generate_secret();
let encrypted_secret = encrypt_value(&ctx.config, &secret)?;
let (backup_codes, encrypted_backup_codes) = generate_backup_codes(config, &ctx.config).await?;
let mut set_cookie_headers = Vec::new();
if config.skip_verification_on_enable {
let updated_user = ctx
.database
.update_user_record(
user.id().as_ref(),
UpdateUser {
two_factor_enabled: Some(true),
..Default::default()
},
)
.await?;
let issued = issue_user_session_with_overrides_record(
ctx,
updated_user.id().as_ref(),
current_session.ip_address().map(str::to_owned),
current_session.user_agent().map(str::to_owned),
current_session,
)
.await
.map_err(SessionIssueError::into_auth_error)?;
ctx.database.delete_session(current_session.token()).await?;
set_cookie_headers.push(create_session_cookie(issued.session.token(), &ctx.config)?);
}
if let Some(existing) = existing {
_ = ctx
.database
.update_two_factor(
existing.id().as_ref(),
UpdateTwoFactor {
secret: Some(encrypted_secret),
backup_codes: Some(encrypted_backup_codes),
verified: Some(config.skip_verification_on_enable),
},
)
.await?;
} else {
_ = ctx
.database
.create_two_factor(CreateTwoFactor {
user_id: user.id().to_string(),
secret: encrypted_secret,
backup_codes: encrypted_backup_codes,
verified: Some(config.skip_verification_on_enable),
..Default::default()
})
.await?;
}
let issuer = body
.issuer
.as_deref()
.filter(|value| !value.is_empty())
.or_else(|| config.issuer.as_deref().filter(|value| !value.is_empty()))
.unwrap_or(&ctx.config.app_name);
let totp_uri = totp_uri(
config,
&secret,
issuer,
user.email().unwrap_or("user"),
true,
);
Ok((
TwoFactorEnabled::Totp {
totp_uri,
backup_codes,
},
set_cookie_headers,
))
}
pub(in crate::two_factor) async fn disable_core(
body: &DisableTwoFactorInput,
user: &impl AuthUser,
current_session: &impl AuthSession,
req: &impl alibi_core::session::SessionRequest,
config: &TwoFactorConfig,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<(StatusResponse, Vec<String>)> {
verify_user_password(
ctx,
user,
body.password.as_deref(),
config.allow_passwordless,
)
.await?;
let updated_user = ctx
.database
.update_user_record(
user.id().as_ref(),
UpdateUser {
two_factor_enabled: Some(false),
..Default::default()
},
)
.await?;
ctx.database.delete_two_factor(user.id().as_ref()).await?;
let issued = issue_user_session_with_overrides_record(
ctx,
updated_user.id().as_ref(),
current_session.ip_address().map(str::to_owned),
current_session.user_agent().map(str::to_owned),
current_session,
)
.await
.map_err(SessionIssueError::into_auth_error)?;
ctx.database.delete_session(current_session.token()).await?;
let dont_remember = read_signed_cookie(req, DONT_REMEMBER_COOKIE_SUFFIX, ctx)
.is_some_and(|value| !value.is_empty());
let mut set_cookie_headers = vec![create_session_cookie_for_dont_remember(
issued.session.token(),
dont_remember,
&ctx.config,
)?];
if dont_remember {
set_cookie_headers.push(create_signed_cookie_header(
ctx.config.current_secret(),
&ctx.config,
DONT_REMEMBER_COOKIE_SUFFIX,
"true",
None,
)?);
}
if let Some(trust_cookie) = read_signed_cookie(req, TRUST_DEVICE_COOKIE_SUFFIX, ctx)
&& !trust_cookie.is_empty()
{
if let Some(trust_identifier) = trust_cookie.split('!').nth(1)
&& !trust_identifier.is_empty()
{
ctx.verifications().delete(trust_identifier).await?;
}
set_cookie_headers.push(clear_cookie_header(
&ctx.config,
TRUST_DEVICE_COOKIE_SUFFIX,
)?);
}
Ok((StatusResponse { status: true }, set_cookie_headers))
}
pub(in crate::two_factor) async fn mark_factor_verified(
two_factor: &TwoFactor,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<()> {
if two_factor.verified() != Some(true) {
_ = ctx
.database
.update_two_factor(
two_factor.id().as_ref(),
UpdateTwoFactor {
verified: Some(true),
..Default::default()
},
)
.await?;
}
Ok(())
}
pub(in crate::two_factor) async fn verify_user_password(
ctx: &AuthContext<impl alibi_core::AuthSchema>,
user: &impl AuthUser,
password: Option<&str>,
allow_passwordless: bool,
) -> AuthResult<()> {
let stored_hash = get_credential_password_hash(ctx, user).await?;
if allow_passwordless && stored_hash.as_deref().is_none_or(str::is_empty) {
return Ok(());
}
let password = password
.filter(|password| !password.is_empty())
.ok_or_else(|| AuthError::bad_request("Invalid password"))?;
let password_config = ctx
.extensions
.get::<super::super::email_password::EmailPasswordConfig>();
let maximum = password_config
.as_ref()
.map_or(128, |config| config.password_max_length);
if password.encode_utf16().count() > maximum {
return Err(AuthError::bad_request("Password too long"));
}
let stored_hash = stored_hash
.filter(|hash| !hash.is_empty())
.ok_or_else(|| AuthError::bad_request("Invalid password"))?;
let hasher = password_config
.as_ref()
.and_then(|config| config.password_hasher.as_ref());
match alibi_core::verify_password(hasher, password, &stored_hash).await {
Ok(()) => Ok(()),
Err(AuthError::InvalidCredentials) => Err(AuthError::bad_request("Invalid password")),
Err(error) => Err(error),
}
}
pub(in crate::two_factor) fn parse_password_body<T: serde::de::DeserializeOwned + 'static>(
req: &AuthRequest,
allow_passwordless: bool,
include_issuer: bool,
) -> Result<T, AuthResponse> {
use super::super::authentication_helpers::{JsonField, JsonFieldKind, parse_body_with_fields};
let fields = [
JsonField::string("password", !allow_passwordless),
JsonField {
name: "method",
kind: JsonFieldKind::OneOf(&["otp", "totp"]),
required: false,
},
JsonField::string("issuer", false),
];
parse_body_with_fields(
req,
if include_issuer {
&fields[..]
} else {
&fields[..1]
},
)
}