Skip to main content

acme_proxy/webadmin/pages/
account.rs

1//! `/ui/account` — the operator's own page, which is only ever about their
2//! second factor.
3//!
4//! Everything here acts on whoever is holding the cookie, which is why no path
5//! carries an id. Managing *other* operators stays a shell command on the host:
6//! this panel has no sign-up page and no user administration, deliberately.
7
8use axum::extract::State;
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use serde::Deserialize;
12use serde_json::{Map, Value, json};
13
14use crate::admin::{mfa, totp};
15use crate::sqlite::admin_user::AdminUser;
16use crate::webadmin::AdminState;
17use crate::webadmin::handlers::mfa::check_step_up;
18use crate::webadmin::pages::auth::{PageEnrolWrite, PageSession, PageSessionWrite};
19use crate::webadmin::pages::error::PageError;
20use crate::webadmin::pages::{chrome, respond, respond_fragment};
21use crate::webadmin::session::AdminClientIp;
22
23#[derive(Debug, Deserialize)]
24pub struct ConfirmForm {
25    pub code: String,
26}
27
28/// The `/ui` twin of [`crate::webadmin::handlers::mfa::StepUpRequest`]: the
29/// password the card's own field collects, pulled in by `hx-include`.
30#[derive(Debug, Default, Deserialize)]
31pub struct StepUpForm {
32    #[serde(default)]
33    pub password: String,
34}
35
36/// [`check_step_up`] with the refusal rendered as this card's banner.
37///
38/// The module's rule -- "the row's state is a banner, the server's problem is a
39/// page" -- puts a wrong password on the banner side: the session is live and
40/// the page is the right page, only this one action was refused. The same now
41/// holds for the rate limit `check_step_up` applies, which is why the status and
42/// the wording are taken from the error rather than hardcoded: a lockout renders
43/// at 429 and says how long to wait, exactly as `post_login` re-renders its own
44/// refusals at their real status.
45async fn refuse_without_password(
46    state: &AdminState,
47    user: &AdminUser,
48    csrf_token: &str,
49    password: &str,
50    client: Option<std::net::IpAddr>,
51) -> Result<Option<Response>, PageError> {
52    let Err(error) = check_step_up(user, password, client, &state.logins) else {
53        return Ok(None);
54    };
55    // The wrong-password case keeps this page's own wording: `AdminError`'s is
56    // "invalid username or password", which is a script's answer to a sign-in
57    // and names a field this card does not have. Every other refusal — today
58    // only the rate limit — carries its own message through, because that one
59    // says how long to wait and no fixed string here could.
60    let message = if error.status == StatusCode::UNAUTHORIZED {
61        "That password is not correct.".to_string()
62    } else {
63        error.message.clone()
64    };
65    let mut context = card_context(state, user, csrf_token).await?;
66    context.insert("flash".to_string(), super::flash_error(error.code, message));
67    Ok(Some(
68        (
69            error.status,
70            respond_fragment(state, "account/_mfa.html", context)?,
71        )
72            .into_response(),
73    ))
74}
75
76/// `GET /ui/account` — the second-factor status card.
77pub async fn get_account(
78    State(state): State<AdminState>,
79    session: PageSession,
80) -> Result<Response, PageError> {
81    let mut context = chrome(&session, "account", "Your account");
82    context.insert("mfa".to_string(), status(&state, &session.auth.user).await?);
83    context.insert(
84        "require_mfa".to_string(),
85        Value::Bool(state.config.admin.require_mfa),
86    );
87    context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
88
89    Ok(respond(
90        &state,
91        session.hx,
92        "account/index.html",
93        "account/_mfa.html",
94        context,
95    )?
96    .into_response())
97}
98
99/// `POST /ui/account/mfa/totp` — begin (or resume) an enrolment.
100///
101/// Resumes rather than restarts when one is already pending: an operator who
102/// reloads after scanning the secret into an app must not be handed a different
103/// one.
104///
105/// Takes the account password when a factor already exists — the card's own
106/// field, pulled in by `hx-include`. See [`check_step_up`].
107pub async fn begin_totp(
108    State(state): State<AdminState>,
109    AdminClientIp(client): AdminClientIp,
110    session: PageEnrolWrite,
111    axum::Form(body): axum::Form<StepUpForm>,
112) -> Result<Response, PageError> {
113    let mut user = session.enrol.user;
114    if let Some(refusal) = refuse_without_password(
115        &state,
116        &user,
117        &session.enrol.session.csrf_token,
118        &body.password,
119        client,
120    )
121    .await?
122    {
123        return Ok(refusal);
124    }
125
126    let enrolment = mfa::resume_or_begin_totp_enrolment(
127        &mut user,
128        &state.config.admin.base_url,
129        state.database.clone(),
130    )
131    .await?;
132
133    let mut context = Map::new();
134    context.insert(
135        "csrf_token".to_string(),
136        Value::String(session.enrol.session.csrf_token.clone()),
137    );
138    context.insert(
139        "enrolment".to_string(),
140        json!({
141            "secret": enrolment.secret_base32,
142            "uri": enrolment.uri,
143            "algorithm": "SHA1",
144            "digits": totp::DIGITS,
145            "period": totp::PERIOD_SECONDS,
146        }),
147    );
148
149    Ok(respond_fragment(&state, "account/_enrol.html", context)?.into_response())
150}
151
152/// `POST /ui/account/mfa/totp/confirm` — prove a code, and receive the recovery
153/// codes once.
154///
155/// A wrong code is a banner on the enrolment step, not an error page: the row's
156/// state is a banner, the server's problem is a page.
157pub async fn confirm_totp(
158    State(state): State<AdminState>,
159    session: PageEnrolWrite,
160    axum::Form(body): axum::Form<ConfirmForm>,
161) -> Result<Response, PageError> {
162    let mut user = session.enrol.user;
163    let keep = session.enrol.session.token_hash.clone();
164
165    let Some(codes) =
166        mfa::confirm_totp_enrolment(&mut user, &body.code, Some(&keep), state.database.clone())
167            .await?
168    else {
169        // Re-render the enrolment step with the same secret still pending, so
170        // the operator can simply try the next code their app shows.
171        let enrolment = mfa::resume_or_begin_totp_enrolment(
172            &mut user,
173            &state.config.admin.base_url,
174            state.database.clone(),
175        )
176        .await?;
177
178        let mut context = Map::new();
179        context.insert(
180            "csrf_token".to_string(),
181            Value::String(session.enrol.session.csrf_token.clone()),
182        );
183        context.insert(
184            "enrolment".to_string(),
185            json!({
186                "secret": enrolment.secret_base32,
187                "uri": enrolment.uri,
188                "algorithm": "SHA1",
189                "digits": totp::DIGITS,
190                "period": totp::PERIOD_SECONDS,
191            }),
192        );
193        context.insert(
194            "flash".to_string(),
195            super::flash_error("bad_request", "That code did not match. Try the next one."),
196        );
197
198        return Ok((
199            StatusCode::BAD_REQUEST,
200            respond_fragment(&state, "account/_enrol.html", context)?,
201        )
202            .into_response());
203    };
204
205    let mut context = card_context(&state, &user, &session.enrol.session.csrf_token).await?;
206    context.insert("recovery_codes".to_string(), json!(codes));
207    Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
208}
209
210/// `POST /ui/account/mfa/totp/disable` — turn the factor off.
211///
212/// Takes the account password ([`check_step_up`]): this is the most
213/// consequential thing a stolen cookie could do here.
214pub async fn disable_totp(
215    State(state): State<AdminState>,
216    AdminClientIp(client): AdminClientIp,
217    session: PageSessionWrite,
218    axum::Form(body): axum::Form<StepUpForm>,
219) -> Result<Response, PageError> {
220    if state.config.admin.require_mfa {
221        // A banner, not a page: the refusal is about this card's own state.
222        let mut context =
223            card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
224        context.insert(
225            "flash".to_string(),
226            super::flash_error(
227                "mfa_required",
228                "This server requires a second factor of every operator.",
229            ),
230        );
231        return Ok((
232            StatusCode::CONFLICT,
233            respond_fragment(&state, "account/_mfa.html", context)?,
234        )
235            .into_response());
236    }
237
238    let mut user = session.auth.user;
239    if let Some(refusal) = refuse_without_password(
240        &state,
241        &user,
242        &session.auth.session.csrf_token,
243        &body.password,
244        client,
245    )
246    .await?
247    {
248        return Ok(refusal);
249    }
250
251    mfa::disable_totp(
252        &mut user,
253        Some(&session.auth.session.token_hash),
254        state.database.clone(),
255    )
256    .await?;
257
258    let mut context = card_context(&state, &user, &session.auth.session.csrf_token).await?;
259    context.insert(
260        "flash".to_string(),
261        super::flash(
262            "warn",
263            "Two-factor authentication is off. Your recovery codes were destroyed \
264             and every other session of yours was signed out.",
265        ),
266    );
267    Ok(respond_fragment(&state, "account/_mfa.html", context)?.into_response())
268}
269
270/// `POST /ui/account/mfa/recovery-codes` — mint a fresh set, **shown once**.
271///
272/// Takes the account password ([`check_step_up`]): superseding the set the
273/// rightful operator would recover with is the same lockout as replacing the
274/// factor itself.
275pub async fn regenerate_recovery_codes(
276    State(state): State<AdminState>,
277    AdminClientIp(client): AdminClientIp,
278    session: PageSessionWrite,
279    axum::Form(body): axum::Form<StepUpForm>,
280) -> Result<Response, PageError> {
281    if !session.auth.user.has_totp() {
282        let mut context =
283            card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
284        context.insert(
285            "flash".to_string(),
286            super::flash_error(
287                "mfa_not_enabled",
288                "There is no second factor for these codes to recover access to.",
289            ),
290        );
291        return Ok((
292            StatusCode::CONFLICT,
293            respond_fragment(&state, "account/_mfa.html", context)?,
294        )
295            .into_response());
296    }
297
298    if let Some(refusal) = refuse_without_password(
299        &state,
300        &session.auth.user,
301        &session.auth.session.csrf_token,
302        &body.password,
303        client,
304    )
305    .await?
306    {
307        return Ok(refusal);
308    }
309
310    let codes = mfa::regenerate_recovery_codes(&session.auth.user, state.database.clone()).await?;
311    let mut context =
312        card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
313    context.insert("recovery_codes".to_string(), json!(codes));
314
315    Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
316}
317
318/// What `GET /api/mfa` answers, for the template.
319async fn status(state: &AdminState, user: &AdminUser) -> Result<Value, PageError> {
320    let remaining = mfa::recovery_codes_remaining(&user.id, state.database.clone()).await?;
321    Ok(json!({
322        "totpEnabled": user.has_totp(),
323        "enrolmentPending": user.has_pending_totp(),
324        "recoveryCodesRemaining": remaining,
325    }))
326}
327
328/// Everything `account/_card.html` reads.
329///
330/// A fragment is rendered standalone, so it cannot inherit the `hx-headers` on
331/// `<body>` — the `csrf_token` has to be inserted by hand or every control in
332/// the swapped fragment answers `403`. Same rule as `pages::accounts::card`.
333async fn card_context(
334    state: &AdminState,
335    user: &AdminUser,
336    csrf_token: &str,
337) -> Result<Map<String, Value>, PageError> {
338    let mut context = Map::new();
339    context.insert(
340        "csrf_token".to_string(),
341        Value::String(csrf_token.to_string()),
342    );
343    context.insert(
344        "user".to_string(),
345        crate::admin::render_admin_user_json(user),
346    );
347    context.insert("mfa".to_string(), status(state, user).await?);
348    context.insert(
349        "require_mfa".to_string(),
350        Value::Bool(state.config.admin.require_mfa),
351    );
352    context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
353    Ok(context)
354}
355
356// No `not_found` here, unlike every sibling in this directory: none of these
357// routes takes an id. There is exactly one account this page can be about, and
358// the session names it.