use serde::Serialize;
use crate::auth::mfa::{
base32_decode_no_pad, build_otpauth_url, confirm_enrolment, consume_backup_code,
promote_session_to_mfa_verified, provision_secret, verify_totp_for_user, BackupConsumeOutcome,
EnrolOutcome, MfaKey, VerifyOutcome,
};
use crate::auth::recovery_admin::check_session_elevated;
use crate::auth::{self, Identity};
use crate::error::Result;
use crate::http::{Request, Response};
use super::handlers::{csrf_token, AdminCtx};
use super::render::BaseContext;
#[derive(Serialize)]
struct MfaVerifyCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
email: String,
error: Option<String>,
}
pub(crate) async fn show_verify(
ctx: &AdminCtx,
identity: Identity,
req: &Request,
) -> Result<Response> {
let view = MfaVerifyCtx {
base: BaseContext::new(Some(&identity), csrf_token(req), &ctx.admin),
page_title: "Two-factor authentication",
email: identity.email.clone(),
error: None,
};
let body = ctx.templates.render("admin/mfa_verify.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_verify(
ctx: &AdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
let form = req.form()?;
let code = form.get("code").unwrap_or("").trim().to_string();
let uniform_failure = |ctx: &AdminCtx| -> Result<Response> {
let view = MfaVerifyCtx {
base: BaseContext::new(Some(&identity), csrf_token(&req), &ctx.admin),
page_title: "Two-factor authentication",
email: identity.email.clone(),
error: Some("Could not verify your code.".to_string()),
};
let body = ctx.templates.render("admin/mfa_verify.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::UNAUTHORIZED))
};
if code.is_empty() {
return uniform_failure(ctx);
}
let cookie = match req.header("cookie") {
Some(c) => c,
None => return uniform_failure(ctx),
};
let token = match auth::session_token_from_cookie(cookie) {
Some(t) => t,
None => return uniform_failure(ctx),
};
let current_session_id = match auth::current_session_id(&ctx.db, &token).await? {
Some(id) => id,
None => return uniform_failure(ctx),
};
let policy = ctx.admin.active_recovery_policy();
let step_seconds = policy.mfa_step_seconds();
let skew_steps = policy.mfa_skew_steps();
let key = MfaKey::from_env()?;
let correlation_id = req.header("x-correlation-id");
let totp_outcome = verify_totp_for_user(
&ctx.db,
identity.user_id,
&code,
step_seconds,
skew_steps,
&key,
)
.await?;
let totp_verified = matches!(totp_outcome, VerifyOutcome::Verified { .. });
let verified = if totp_verified {
true
} else {
match totp_outcome {
VerifyOutcome::Invalid => {
let backup_outcome = consume_backup_code(
&ctx.db,
&req,
identity.user_id,
&code,
"login",
correlation_id,
)
.await?;
matches!(backup_outcome, BackupConsumeOutcome::Consumed { .. })
}
VerifyOutcome::Replay { .. }
| VerifyOutcome::NotEnrolled
| VerifyOutcome::Verified { .. } => false,
}
};
if !verified {
return uniform_failure(ctx);
}
let new_token =
promote_session_to_mfa_verified(&ctx.db, current_session_id, identity.user_id).await?;
let cookie = format!(
"{}={new_token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=1209600",
auth::SESSION_COOKIE
);
Ok(Response::redirect("/admin").with_header("set-cookie", cookie))
}
#[derive(Serialize)]
struct MfaEnrollCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
otpauth_url: String,
secret_base32: String,
error: Option<String>,
}
#[derive(Serialize)]
struct MfaEnrollCompleteCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
plain_backup_codes: Vec<String>,
}
pub(crate) async fn show_enroll(
ctx: &AdminCtx,
identity: Identity,
req: &Request,
) -> Result<Response> {
let cookie = req.header("cookie");
let token = cookie.and_then(auth::session_token_from_cookie);
let session_id = if let Some(t) = token {
auth::current_session_id(&ctx.db, &t).await?
} else {
None
};
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(Response::redirect(
"/admin/reauth?return_to=/admin/account/mfa/enroll",
));
}
let provisioned = provision_secret();
let issuer = ctx.admin.branding().site_title.as_str();
let step_seconds = ctx.admin.active_recovery_policy().mfa_step_seconds();
let otpauth_url = build_otpauth_url(issuer, &identity.email, &provisioned.base32, step_seconds);
let view = MfaEnrollCtx {
base: BaseContext::new(Some(&identity), csrf_token(req), &ctx.admin),
page_title: "Set up two-factor authentication",
otpauth_url,
secret_base32: provisioned.base32,
error: None,
};
let body = ctx.templates.render("admin/mfa_enroll.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_enroll(
ctx: &AdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
let cookie = req.header("cookie");
let token = cookie.and_then(auth::session_token_from_cookie);
let session_id = if let Some(t) = token {
auth::current_session_id(&ctx.db, &t).await?
} else {
None
};
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(Response::redirect(
"/admin/reauth?return_to=/admin/account/mfa/enroll",
));
}
let form = req.form()?;
let secret_base32 = form.get("secret_base32").unwrap_or("").to_string();
let code_str = form.get("code").unwrap_or("").trim().to_string();
let render_with_fresh_secret = |ctx: &AdminCtx, error: &str| -> Result<Response> {
let provisioned = provision_secret();
let issuer = ctx.admin.branding().site_title.as_str();
let step_seconds = ctx.admin.active_recovery_policy().mfa_step_seconds();
let otpauth_url =
build_otpauth_url(issuer, &identity.email, &provisioned.base32, step_seconds);
let view = MfaEnrollCtx {
base: BaseContext::new(Some(&identity), csrf_token(&req), &ctx.admin),
page_title: "Set up two-factor authentication",
otpauth_url,
secret_base32: provisioned.base32,
error: Some(error.to_string()),
};
let body = ctx.templates.render("admin/mfa_enroll.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::UNAUTHORIZED))
};
let Some(secret_bytes) = base32_decode_no_pad(&secret_base32) else {
return render_with_fresh_secret(ctx, "Could not verify your code.");
};
if secret_bytes.len() != 20 {
return render_with_fresh_secret(ctx, "Could not verify your code.");
}
let candidate_code = match code_str.parse::<u32>() {
Ok(n) if n < 1_000_000 => n,
_ => return render_with_fresh_secret(ctx, "Could not verify your code."),
};
let policy = ctx.admin.active_recovery_policy();
let step_seconds = policy.mfa_step_seconds();
let skew_steps = policy.mfa_skew_steps();
let key = MfaKey::from_env()?;
let correlation_id = req.header("x-correlation-id");
let outcome = confirm_enrolment(
&ctx.db,
&req,
identity.user_id,
&secret_bytes,
candidate_code,
step_seconds,
skew_steps,
&key,
1, correlation_id,
)
.await?;
match outcome {
EnrolOutcome::Enrolled { plain_backup_codes } => {
let view = MfaEnrollCompleteCtx {
base: BaseContext::new(Some(&identity), csrf_token(&req), &ctx.admin),
page_title: "Two-factor authentication enabled",
plain_backup_codes,
};
let body = ctx
.templates
.render("admin/mfa_enroll_complete.html", &view)?;
Ok(Response::html(body))
}
EnrolOutcome::InvalidCode => {
let view = MfaEnrollCtx {
base: BaseContext::new(Some(&identity), csrf_token(&req), &ctx.admin),
page_title: "Set up two-factor authentication",
otpauth_url: build_otpauth_url(
ctx.admin.branding().site_title.as_str(),
&identity.email,
&secret_base32,
step_seconds,
),
secret_base32,
error: Some("Could not verify your code.".to_string()),
};
let body = ctx.templates.render("admin/mfa_enroll.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::UNAUTHORIZED))
}
EnrolOutcome::AlreadyEnrolled => {
Ok(Response::redirect("/admin/account/sessions"))
}
}
}
use crate::auth::mfa::{regenerate_backup_codes, RegenOutcome};
#[derive(Serialize)]
struct MfaRegenerateCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
error: Option<String>,
}
#[derive(Serialize)]
struct MfaRegenerateCompleteCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
plain_backup_codes: Vec<String>,
previous_codes_invalidated: u32,
}
pub(crate) async fn show_regenerate(
ctx: &AdminCtx,
identity: Identity,
req: &Request,
) -> Result<Response> {
let cookie = req.header("cookie");
let token = cookie.and_then(auth::session_token_from_cookie);
let session_id = if let Some(t) = token {
auth::current_session_id(&ctx.db, &t).await?
} else {
None
};
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(Response::redirect(
"/admin/reauth?return_to=/admin/account/mfa/regenerate-codes",
));
}
let view = MfaRegenerateCtx {
base: BaseContext::new(Some(&identity), csrf_token(req), &ctx.admin),
page_title: "Generate new backup codes",
error: None,
};
let body = ctx.templates.render("admin/mfa_regenerate.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_regenerate(
ctx: &AdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
let cookie = req.header("cookie");
let token = cookie.and_then(auth::session_token_from_cookie);
let session_id = if let Some(t) = token {
auth::current_session_id(&ctx.db, &t).await?
} else {
None
};
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(Response::redirect(
"/admin/reauth?return_to=/admin/account/mfa/regenerate-codes",
));
}
let correlation_id = req.header("x-correlation-id");
let outcome = regenerate_backup_codes(&ctx.db, &req, identity.user_id, correlation_id).await?;
match outcome {
RegenOutcome::Regenerated {
plain_backup_codes,
previous_codes_invalidated,
} => {
let view = MfaRegenerateCompleteCtx {
base: BaseContext::new(Some(&identity), csrf_token(&req), &ctx.admin),
page_title: "New backup codes generated",
plain_backup_codes,
previous_codes_invalidated,
};
let body = ctx
.templates
.render("admin/mfa_regenerate_complete.html", &view)?;
Ok(Response::html(body))
}
RegenOutcome::NotEnrolled => {
Ok(Response::redirect("/admin/account/sessions"))
}
}
}
use crate::auth::mfa::{disable_mfa, DisableOutcome};
#[derive(Serialize)]
struct MfaDisableCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
error: Option<String>,
}
pub(crate) async fn show_disable(
ctx: &AdminCtx,
identity: Identity,
req: &Request,
) -> Result<Response> {
let cookie = req.header("cookie");
let token = cookie.and_then(auth::session_token_from_cookie);
let session_id = if let Some(t) = token {
auth::current_session_id(&ctx.db, &t).await?
} else {
None
};
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(Response::redirect(
"/admin/reauth?return_to=/admin/account/mfa/disable",
));
}
let view = MfaDisableCtx {
base: BaseContext::new(Some(&identity), csrf_token(req), &ctx.admin),
page_title: "Disable two-factor authentication",
error: None,
};
let body = ctx.templates.render("admin/mfa_disable.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_disable(
ctx: &AdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
let cookie = req.header("cookie");
let token = cookie.and_then(auth::session_token_from_cookie);
let session_id = if let Some(t) = token {
auth::current_session_id(&ctx.db, &t).await?
} else {
None
};
let elevated = match session_id {
Some(id) => check_session_elevated(&ctx.db, id).await?,
None => false,
};
if !elevated {
return Ok(Response::redirect(
"/admin/reauth?return_to=/admin/account/mfa/disable",
));
}
let correlation_id = req.header("x-correlation-id");
let outcome = disable_mfa(&ctx.db, &req, identity.user_id, correlation_id).await?;
match outcome {
DisableOutcome::Disabled {
sessions_revoked: _,
} => {
let clear = format!(
"{}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
auth::SESSION_COOKIE
);
Ok(Response::redirect("/admin/login?mfa_disabled=1").with_header("set-cookie", clear))
}
DisableOutcome::NotEnrolled => {
Ok(Response::redirect("/admin/account/sessions"))
}
DisableOutcome::PolicyRequired => {
Ok(
Response::html("Forbidden: your administrator requires MFA.")
.with_status(hyper::StatusCode::FORBIDDEN),
)
}
}
}