use topcoat::{
context::Cx,
router::{
Body,
error::{forbidden, not_found, see_other, too_many_requests},
request::client_ip,
},
view::BoxView,
};
use super::{
Enrolment, FactorKind, TwoFactor, accept, accept_factor, email, enrolment,
issue_recovery_codes,
pages::{self, Method, Settings},
remove_all, secret, start, start_authenticator, totp,
};
use crate::{
auth::{
SignedIn, infrastructure_failure,
realm::{hosted, panel_prefix},
session::{replace_sessions, start_once},
signed,
token::run_serializable,
unauthenticated_error,
},
flash::set_notification,
outcome::Notification,
topcoat_compat::async_page,
};
pub(super) const INTENT_FIELD: &str = "intent";
pub(super) const PASSWORD_FIELD: &str = "current_password";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Intent {
Start,
StartEmail,
Send,
Confirm,
Cancel,
RecoveryCodes,
Disable,
}
impl Intent {
fn parse(value: &str) -> Option<Self> {
Some(match value {
"start" => Self::Start,
"start-email" => Self::StartEmail,
"send" => Self::Send,
"confirm" => Self::Confirm,
"cancel" => Self::Cancel,
"recovery-codes" => Self::RecoveryCodes,
"disable" => Self::Disable,
_ => return None,
})
}
fn checks_secret(self) -> bool {
matches!(
self,
Self::Start | Self::StartEmail | Self::Confirm | Self::RecoveryCodes | Self::Disable
)
}
}
pub(crate) fn settings_url(cx: &Cx) -> String {
format!("{}/two-factor", panel_prefix(cx))
}
fn configuring(cx: &Cx) -> topcoat::Result<(&SignedIn, &TwoFactor)> {
let signed = signed(cx).ok_or_else(|| unauthenticated_error(cx))?;
let two_factor = signed
.realm
.auth
.two_factor_config()
.ok_or_else(not_found)?;
Ok((signed, two_factor))
}
async fn standing<'a>(
cx: &'a Cx,
signed: &SignedIn,
two_factor: &TwoFactor,
enrolment: &Enrolment,
refused: bool,
) -> topcoat::Result<BoxView<'a>> {
let address = || signed.user.email().unwrap_or_default().to_string();
let settings = match (&enrolment.confirmed, &enrolment.unconfirmed) {
(Some(factor), _) => Settings::On {
method: match factor.kind {
FactorKind::EmailCode => Method::Email(address()),
_ => Method::App,
},
recovery_codes: enrolment.recovery_codes,
refused,
},
(None, Some(factor)) if factor.kind == FactorKind::EmailCode => Settings::Email {
address: address(),
refused,
},
(None, Some(factor)) => match secret(cx, factor)? {
Some(secret) => setup(cx, signed, &secret, refused)?,
None => off(signed, two_factor, false),
},
(None, None) => off(signed, two_factor, false),
};
pages::settings(cx, settings).await
}
fn off(signed: &SignedIn, two_factor: &TwoFactor, refused: bool) -> Settings {
Settings::Off {
email: two_factor.mails() && signed.user.email().is_some(),
refused,
}
}
fn setup(cx: &Cx, signed: &SignedIn, secret: &[u8], refused: bool) -> topcoat::Result<Settings> {
let account = signed
.user
.email()
.unwrap_or_else(|| signed.user.display_name())
.to_string();
Ok(Settings::App {
uri: totp::uri(secret, &hosted(cx)?.brand_name(cx), &account),
secret: totp::encode(secret),
refused,
})
}
pub(in crate::auth) fn settings_page(cx: &Cx, _body: Body) -> BoxView<'_> {
async_page(async move {
let (signed, two_factor) = configuring(cx)?;
crate::csrf::ensure_token(cx);
let mut db = crate::db::db(cx)?;
let enrolment = enrolment(cx, &mut db, &signed.user.user_id()).await?;
standing(cx, signed, two_factor, &enrolment, false).await
})
}
pub(in crate::auth) fn settings_post(cx: &Cx, body: Body) -> BoxView<'_> {
async_page(async move {
let (signed, two_factor) = configuring(cx)?;
let form = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
let intent = form
.last(INTENT_FIELD)
.and_then(Intent::parse)
.ok_or_else(forbidden)?;
let code = form.last(super::challenge::CODE_FIELD).unwrap_or_default();
let user_id = signed.user.user_id();
if intent.checks_secret() && !two_factor.throttle.attempt(&user_id, None) {
tracing::warn!(
panel = %signed.realm.prefix,
client = ?client_ip(cx),
"two-factor settings attempt throttled"
);
return Err(too_many_requests(two_factor.throttle.window_secs()).into());
}
let authenticator = signed.realm.auth.authenticator().ok_or_else(not_found)?;
if matches!(intent, Intent::Start | Intent::StartEmail) {
let password = form.last(PASSWORD_FIELD).unwrap_or_default();
if !authenticator
.confirm_password(cx, signed.user.as_ref(), password)
.await
.map_err(infrastructure_failure)?
{
return pages::settings(cx, off(signed, two_factor, true)).await;
}
}
let user_type = authenticator.user_type();
let mut started = None;
let settled = run_serializable!(cx, |tx| async {
let enrolment = enrolment(cx, tx, &user_id).await?;
let settled = match (intent, &enrolment.confirmed, &enrolment.unconfirmed) {
(Intent::Start, None, _) => {
Settled::Setup(start_authenticator(cx, tx, &user_id).await?)
}
(Intent::StartEmail, None, _)
if matches!(
off(signed, two_factor, false),
Settings::Off { email: true, .. }
) =>
{
start(cx, tx, &user_id, FactorKind::EmailCode, String::new()).await?;
Settled::Mail { notice: false }
}
(Intent::Send, Some(factor), _) | (Intent::Send, None, Some(factor))
if factor.kind == FactorKind::EmailCode =>
{
Settled::Mail { notice: true }
}
(Intent::Confirm, None, Some(factor)) => {
if !accept_factor(cx, tx, factor, code).await? {
return Ok(Settled::Refused(Box::new(enrolment)));
}
super::AuthFactor::filter(super::AuthFactor::fields().id().eq(factor.id))
.update()
.confirmed_at(Some(jiff::Timestamp::now()))
.exec(&mut *tx)
.await
.map_err(infrastructure_failure)?;
let codes = issue_recovery_codes(cx, tx, &user_id).await?;
let session = start_once(cx, &mut started).await?;
replace_sessions(cx, tx, signed, user_type, &session).await?;
Settled::Codes { codes, first: true }
}
(Intent::Cancel, None, Some(_)) => {
remove_all(cx, tx, &user_id).await?;
Settled::Unchanged
}
(Intent::RecoveryCodes, Some(_), _) => {
if !accept(cx, tx, &user_id, code).await? {
return Ok(Settled::Refused(Box::new(enrolment)));
}
let codes = issue_recovery_codes(cx, tx, &user_id).await?;
Settled::Codes {
codes,
first: false,
}
}
(Intent::Disable, Some(_), _) => {
if !accept(cx, tx, &user_id, code).await? {
return Ok(Settled::Refused(Box::new(enrolment)));
}
remove_all(cx, tx, &user_id).await?;
let session = start_once(cx, &mut started).await?;
replace_sessions(cx, tx, signed, user_type, &session).await?;
Settled::Off
}
_ => Settled::Unchanged,
};
Ok(settled)
})?;
match settled {
Settled::Setup(secret) => pages::settings(cx, setup(cx, signed, &secret, false)?).await,
Settled::Mail { notice } => {
email::send(cx, two_factor, signed.user.as_ref())
.await?
.or_too_many(two_factor)?;
if notice {
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-two-factor-sent-toast")),
);
}
Err(see_other(settings_url(cx)).into())
}
Settled::Refused(enrolment) => standing(cx, signed, two_factor, &enrolment, true).await,
Settled::Codes { codes, first } => {
two_factor.throttle.clear(&user_id, None);
pages::settings(cx, Settings::Codes { codes, first }).await
}
Settled::Off => {
two_factor.throttle.clear(&user_id, None);
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-two-factor-off-toast")),
);
Err(see_other(settings_url(cx)).into())
}
Settled::Unchanged => Err(see_other(settings_url(cx)).into()),
}
})
}
enum Settled {
Setup(Vec<u8>),
Mail { notice: bool },
Refused(Box<Enrolment>),
Codes { codes: Vec<String>, first: bool },
Off,
Unchanged,
}