Skip to main content

acme_proxy_admin/webadmin/pages/
account.rs

1//! `/ui/account` — the operator's own page: password, second factor, and
2//! their own live sessions.
3//!
4//! Everything here acts on whoever is holding the cookie, which is why no path
5//! carries an id — the one apparent exception, `/ui/account/sessions/{id}`,
6//! still names only *which session*, never a different account. Managing
7//! *another* operator is `pages::operators`, not this module: the two are kept
8//! apart because every route there sits behind a password re-entry
9//! (`check_step_up`) this operator's own actions never need. `create`/`passwd`
10//! stay a shell command on the host either way — this panel has no sign-up
11//! page, and minting a credential is where that line is drawn.
12
13use axum::extract::{Path, State};
14use axum::http::StatusCode;
15use axum::response::{IntoResponse, Response};
16use serde::Deserialize;
17use serde_json::{Map, Value, json};
18
19use crate::admin;
20use crate::admin::{mfa, totp};
21use crate::webadmin::AdminState;
22use crate::webadmin::error::AdminError;
23use crate::webadmin::handlers::Caller;
24use crate::webadmin::handlers::account::apply_revoke_own_session;
25use crate::webadmin::handlers::mfa::verify_current_password;
26use crate::webadmin::handlers::paging::PageParams;
27use crate::webadmin::pages::auth::{PageEnrolWrite, PageSelfServiceWrite, PageSession};
28use crate::webadmin::pages::error::PageError;
29use crate::webadmin::pages::{chrome, respond, respond_fragment};
30use crate::webadmin::session::{AdminClientIp, clearing_cookie};
31use acme_proxy_store::admin_session::AdminSession;
32use acme_proxy_store::admin_user::AdminUser;
33
34/// The form of `POST /ui/account/mfa/totp/confirm`, the `/ui` twin of
35/// [`crate::webadmin::handlers::mfa::ConfirmRequest`].
36#[derive(Debug, Deserialize)]
37pub struct ConfirmForm {
38    pub code: String,
39}
40
41/// The `/ui` twin of [`crate::webadmin::handlers::mfa::StepUpRequest`]: the
42/// password the card's own field collects, pulled in by `hx-include`.
43#[derive(Debug, Default, Deserialize)]
44pub struct StepUpForm {
45    #[serde(default)]
46    pub password: String,
47}
48
49/// [`check_step_up`] with the refusal rendered as this card's banner.
50///
51/// The module's rule -- "the row's state is a banner, the server's problem is a
52/// page" -- puts a wrong password on the banner side: the session is live and
53/// the page is the right page, only this one action was refused. The same now
54/// holds for the rate limit `check_step_up` applies, which is why the status and
55/// the wording are taken from the error rather than hardcoded: a lockout renders
56/// at 429 and says how long to wait, exactly as `post_login` re-renders its own
57/// refusals at their real status.
58async fn refuse_on_the_card(
59    state: &AdminState,
60    user: &AdminUser,
61    csrf_token: &str,
62    error: &AdminError,
63) -> Result<Response, PageError> {
64    // `refuse_with_card` keeps this page's own wording for the wrong-password
65    // case (`AdminError`'s is "invalid username or password", a script's answer
66    // to a sign-in, naming a field this card does not have) and carries every
67    // other refusal's message through — the rate limit, which says how long to
68    // wait, and the two the shared actions raise. It also keeps the error's
69    // *headers*, which a rebuilt response lost: a `429` without its
70    // `Retry-After`.
71    let context = card_context(state, user, csrf_token).await?;
72    super::refuse_with_card(state, "account/_mfa.html", context, error)
73}
74
75/// `GET /ui/account` — the second-factor status card, the password card, and
76/// this operator's own live sessions.
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    context.insert(
89        "min_password_length".to_string(),
90        json!(crate::admin::password::MIN_PASSWORD_LEN),
91    );
92    insert_own_sessions(&mut context, &state, &session.auth).await?;
93
94    Ok(respond(
95        &state,
96        session.hx,
97        "account/index.html",
98        "account/_mfa.html",
99        context,
100    )?
101    .into_response())
102}
103
104/// `POST /ui/account/mfa/totp` — begin (or resume) an enrolment.
105///
106/// Resumes rather than restarts when one is already pending: an operator who
107/// reloads after scanning the secret into an app must not be handed a different
108/// one.
109///
110/// Takes the account password when a factor already exists — the card's own
111/// field, pulled in by `hx-include`. The check itself is the shared action's,
112/// which `/api` makes too.
113pub async fn begin_totp(
114    State(state): State<AdminState>,
115    AdminClientIp(client): AdminClientIp,
116    session: PageEnrolWrite,
117    axum::Form(body): axum::Form<StepUpForm>,
118) -> Result<Response, PageError> {
119    let mut user = session.enrol.user;
120    let enrolment = match crate::webadmin::handlers::mfa::begin_totp_for(
121        &state,
122        &mut user,
123        &body.password,
124        client,
125    )
126    .await
127    {
128        Ok(enrolment) => enrolment,
129        Err(error) => {
130            return refuse_on_the_card(&state, &user, &session.enrol.session.csrf_token, &error)
131                .await;
132        }
133    };
134
135    let mut context = Map::new();
136    context.insert(
137        "csrf_token".to_string(),
138        Value::String(session.enrol.session.csrf_token.clone()),
139    );
140    context.insert(
141        "enrolment".to_string(),
142        json!({
143            "secret": enrolment.secret_base32,
144            "uri": enrolment.uri,
145            "algorithm": "SHA1",
146            "digits": totp::DIGITS,
147            "period": totp::PERIOD_SECONDS,
148        }),
149    );
150
151    Ok(respond_fragment(&state, "account/_enrol.html", context)?.into_response())
152}
153
154/// `POST /ui/account/mfa/totp/confirm` — prove a code, and receive the recovery
155/// codes once.
156///
157/// A wrong code is a banner on the enrolment step, not an error page: the row's
158/// state is a banner, the server's problem is a page.
159pub async fn confirm_totp(
160    State(state): State<AdminState>,
161    AdminClientIp(client): AdminClientIp,
162    session: PageEnrolWrite,
163    request_context: acme_proxy_core::audit::RequestContext,
164    axum::Form(body): axum::Form<ConfirmForm>,
165) -> Result<Response, PageError> {
166    let mut user = session.enrol.user;
167    let keep = session.enrol.session.token_hash.clone();
168
169    let Some(codes) = crate::webadmin::handlers::mfa::confirm_totp_for(
170        &state,
171        &request_context,
172        &mut user,
173        &body.code,
174        &keep,
175        client,
176    )
177    .await?
178    else {
179        // Re-render the enrolment step with the same secret still pending, so
180        // the operator can simply try the next code their app shows.
181        let enrolment = mfa::resume_or_begin_totp_enrolment(
182            &mut user,
183            &state.config.admin.base_url,
184            state.database.clone(),
185        )
186        .await?;
187
188        let mut context = Map::new();
189        context.insert(
190            "csrf_token".to_string(),
191            Value::String(session.enrol.session.csrf_token.clone()),
192        );
193        context.insert(
194            "enrolment".to_string(),
195            json!({
196                "secret": enrolment.secret_base32,
197                "uri": enrolment.uri,
198                "algorithm": "SHA1",
199                "digits": totp::DIGITS,
200                "period": totp::PERIOD_SECONDS,
201            }),
202        );
203        context.insert(
204            "flash".to_string(),
205            super::flash_error("bad_request", "That code did not match. Try the next one."),
206        );
207
208        return Ok((
209            StatusCode::BAD_REQUEST,
210            respond_fragment(&state, "account/_enrol.html", context)?,
211        )
212            .into_response());
213    };
214
215    let mut context = card_context(&state, &user, &session.enrol.session.csrf_token).await?;
216    context.insert("recovery_codes".to_string(), json!(codes));
217    Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
218}
219
220/// `POST /ui/account/mfa/totp/disable` — turn the factor off.
221///
222/// Takes the account password, as the shared action requires: this is the most
223/// consequential thing a stolen cookie could do here.
224pub async fn disable_totp(
225    State(state): State<AdminState>,
226    AdminClientIp(client): AdminClientIp,
227    session: PageSelfServiceWrite,
228    request_context: acme_proxy_core::audit::RequestContext,
229    axum::Form(body): axum::Form<StepUpForm>,
230) -> Result<Response, PageError> {
231    let mut user = session.auth.user;
232    // A banner, not a page: every refusal here — the server requiring a factor,
233    // a wrong password, a rate limit — is about this card's own state.
234    if let Err(error) = crate::webadmin::handlers::mfa::disable_totp_for(
235        &state,
236        &request_context,
237        &mut user,
238        &body.password,
239        &session.auth.session.token_hash,
240        client,
241    )
242    .await
243    {
244        return refuse_on_the_card(&state, &user, &session.auth.session.csrf_token, &error).await;
245    }
246
247    let mut context = card_context(&state, &user, &session.auth.session.csrf_token).await?;
248    context.insert(
249        "flash".to_string(),
250        super::flash(
251            "warn",
252            "Two-factor authentication is off. Your recovery codes were destroyed \
253             and every other session of yours was signed out.",
254        ),
255    );
256    Ok(respond_fragment(&state, "account/_mfa.html", context)?.into_response())
257}
258
259/// `POST /ui/account/mfa/recovery-codes` — mint a fresh set, **shown once**.
260///
261/// Takes the account password, as the shared action requires: superseding the
262/// set the rightful operator would recover with is the same lockout as
263/// replacing the factor itself.
264pub async fn regenerate_recovery_codes(
265    State(state): State<AdminState>,
266    AdminClientIp(client): AdminClientIp,
267    session: PageSelfServiceWrite,
268    request_context: acme_proxy_core::audit::RequestContext,
269    axum::Form(body): axum::Form<StepUpForm>,
270) -> Result<Response, PageError> {
271    let codes = match crate::webadmin::handlers::mfa::regenerate_recovery_codes_for(
272        &state,
273        &request_context,
274        &session.auth.user,
275        &body.password,
276        client,
277    )
278    .await
279    {
280        Ok(codes) => codes,
281        Err(error) => {
282            return refuse_on_the_card(
283                &state,
284                &session.auth.user,
285                &session.auth.session.csrf_token,
286                &error,
287            )
288            .await;
289        }
290    };
291
292    let mut context =
293        card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
294    context.insert("recovery_codes".to_string(), json!(codes));
295
296    Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
297}
298
299/// The form of `POST /ui/account/password`, the `/ui` twin of
300/// [`crate::webadmin::handlers::account::ChangePasswordRequest`].
301#[derive(Debug, Deserialize)]
302pub struct ChangePasswordForm {
303    pub current_password: String,
304    pub new_password: String,
305}
306
307/// The notification-address form. A blank `contact` clears the address.
308#[derive(Debug, Default, Deserialize)]
309pub struct ContactForm {
310    #[serde(default)]
311    pub current_password: String,
312    #[serde(default)]
313    pub contact: String,
314}
315
316/// Everything `account/_contact.html` reads: the token (a fragment rendered
317/// standalone cannot inherit `<body>`'s `hx-headers`) and the operator.
318/// `typed` is what the form last sent, echoed back on a refusal so a mistyped
319/// address can be corrected rather than retyped.
320fn contact_card_context(
321    csrf_token: &str,
322    user: &AdminUser,
323    typed: Option<&str>,
324) -> Map<String, Value> {
325    let mut context = Map::new();
326    context.insert(
327        "csrf_token".to_string(),
328        Value::String(csrf_token.to_string()),
329    );
330    context.insert(
331        "user".to_string(),
332        crate::admin::render_admin_user_json(user),
333    );
334    if let Some(typed) = typed {
335        context.insert(
336            "contact_input".to_string(),
337            Value::String(typed.to_string()),
338        );
339    }
340    context
341}
342
343/// `POST /ui/account/contact` — set or clear the address this operator's own
344/// security notifications go to.
345///
346/// The `/ui` twin of [`crate::webadmin::handlers::account::change_contact`],
347/// which says why the password is asked for. A wrong password, a rate limit and
348/// an address that is not a mailbox are all this card's own banner.
349pub async fn change_contact(
350    State(state): State<AdminState>,
351    AdminClientIp(client): AdminClientIp,
352    session: PageSelfServiceWrite,
353    request_context: acme_proxy_core::audit::RequestContext,
354    axum::Form(form): axum::Form<ContactForm>,
355) -> Result<Response, PageError> {
356    let caller = session.auth.user.clone();
357    let csrf_token = session.auth.session.csrf_token.clone();
358
359    if let Err(error) =
360        verify_current_password(&caller, &form.current_password, client, &state.logins).await
361    {
362        return super::refuse_with_card(
363            &state,
364            "account/_contact.html",
365            contact_card_context(&csrf_token, &caller, Some(&form.contact)),
366            &error,
367        );
368    }
369
370    let mut target = caller.clone();
371    if let Err(error) = crate::webadmin::handlers::operators::apply_contact_change(
372        &state,
373        &caller,
374        &mut target,
375        Some(form.contact.as_str()),
376        client,
377        &request_context,
378        "ui",
379    )
380    .await
381    {
382        if error.status.is_server_error() {
383            return Err(error.into());
384        }
385        return super::refuse_with_card(
386            &state,
387            "account/_contact.html",
388            contact_card_context(&csrf_token, &caller, Some(&form.contact)),
389            &error,
390        );
391    }
392
393    let mut context = contact_card_context(&csrf_token, &target, None);
394    let banner = match &target.contact_email {
395        Some(address) => super::flash("ok", format!("Security notifications now go to {address}.")),
396        None => super::flash(
397            "warn",
398            "No address is on file: security notifications cannot reach you.",
399        ),
400    };
401    context.insert("flash".to_string(), banner);
402    Ok(respond_fragment(&state, "account/_contact.html", context)?.into_response())
403}
404
405/// Everything `account/_password_card.html` reads. Unlike [`card_context`]
406/// there is no second-factor state to report, but the `csrf_token` rule is
407/// the same: a fragment rendered standalone cannot inherit `<body>`'s
408/// `hx-headers`, so this card's own form carries it explicitly.
409fn password_card_context(csrf_token: &str) -> Map<String, Value> {
410    let mut context = Map::new();
411    context.insert(
412        "csrf_token".to_string(),
413        Value::String(csrf_token.to_string()),
414    );
415    // A client-side hint only -- `check_password_policy` is what actually
416    // enforces it, so tying the two together is about the message staying
417    // true rather than about anything security-relevant.
418    context.insert(
419        "min_password_length".to_string(),
420        json!(crate::admin::password::MIN_PASSWORD_LEN),
421    );
422    context
423}
424
425/// `POST /ui/account/password` — change this operator's own password.
426///
427/// Takes the current password and the new one; on success every *other*
428/// session of this operator is revoked and the one making this request stays
429/// signed in. The change itself is
430/// `handlers::account::change_own_password_for`, which `/api` calls too.
431pub async fn change_password(
432    State(state): State<AdminState>,
433    AdminClientIp(client): AdminClientIp,
434    session: PageSelfServiceWrite,
435    request_context: acme_proxy_core::audit::RequestContext,
436    axum::Form(body): axum::Form<ChangePasswordForm>,
437) -> Result<Response, PageError> {
438    let mut user = session.auth.user;
439    let csrf_token = session.auth.session.csrf_token.clone();
440    let mut fragment_context = password_card_context(&csrf_token);
441
442    // Every refusal is a banner on this card, and its wording is the API's:
443    // one change, one set of sentences, whichever surface asked.
444    if let Err(error) = crate::webadmin::handlers::account::change_own_password_for(
445        &state,
446        &request_context,
447        &mut user,
448        &body.current_password,
449        &body.new_password,
450        &session.auth.session.token_hash,
451        client,
452    )
453    .await
454    {
455        return super::refuse_with_card(&state, "account/_password.html", fragment_context, &error);
456    }
457
458    fragment_context.insert(
459        "flash".to_string(),
460        super::flash(
461            "ok",
462            "Your password was changed. Every other session of yours was signed out.",
463        ),
464    );
465    Ok(respond_fragment(&state, "account/_password.html", fragment_context)?.into_response())
466}
467
468/// `POST /ui/account/sessions/{id}/revoke` — end one of this operator's own
469/// sessions.
470///
471/// No step-up: the same trust level as `/ui/logout` (sign out here, or
472/// everywhere), not the operators surface's "act on someone else's account".
473/// Revoking the session making *this* request is not a special case to guard
474/// against — it behaves exactly like `/ui/logout` without `?all=true`, landing
475/// back on the sign-in page with the cookie cleared, rather than re-rendering a
476/// fragment for a session that no longer exists.
477pub async fn revoke_own_session(
478    State(state): State<AdminState>,
479    Path(id): Path<String>,
480    session: PageSelfServiceWrite,
481    request_context: acme_proxy_core::audit::RequestContext,
482) -> Result<Response, PageError> {
483    let was_current =
484        apply_revoke_own_session(&state, &Caller::ui(&session.auth, &request_context), &id).await?;
485
486    if was_current {
487        let mut response = crate::webadmin::pages::error::redirect(
488            crate::webadmin::pages::error::LOGIN_PATH,
489            session.hx,
490        );
491        if let Ok(value) = axum::http::HeaderValue::from_str(&clearing_cookie()) {
492            response
493                .headers_mut()
494                .insert(axum::http::header::SET_COOKIE, value);
495        }
496        return Ok(response);
497    }
498
499    let mut context = Map::new();
500    context.insert(
501        "csrf_token".to_string(),
502        Value::String(session.auth.session.csrf_token.clone()),
503    );
504    insert_own_sessions(&mut context, &state, &session.auth).await?;
505    context.insert("flash".to_string(), super::flash("ok", "Session revoked."));
506    Ok(respond_fragment(&state, "account/_sessions.html", context)?.into_response())
507}
508
509/// Everything `partials/_sessions_table.html` reads for this operator's own
510/// sessions -- newest first, each marked whether it is the one making the
511/// current request (see [`admin::render_admin_session_detail_json`]), and no
512/// step-up prefix: revoking one's own session sits at "sign out" trust level,
513/// not the operators surface's.
514///
515/// One page's worth: an operator accumulates a handful of browser sessions,
516/// never enough to need the pager the CLI's unbounded `admin session list`
517/// does.
518async fn insert_own_sessions(
519    context: &mut Map<String, Value>,
520    state: &AdminState,
521    auth: &crate::webadmin::session::Authenticated,
522) -> Result<(), PageError> {
523    let page = PageParams::default().resolve(&state.config);
524    let (sessions, _total) =
525        AdminSession::search(Some(auth.user.id), page.limit, page.offset, &state.database).await?;
526    let rows: Vec<Value> = sessions
527        .iter()
528        .map(|s| admin::render_admin_session_detail_json(s, &auth.session.token_hash))
529        .collect();
530
531    context.insert("sessions".to_string(), Value::Array(rows));
532    context.insert(
533        "sessions_revoke_prefix".to_string(),
534        Value::String("/ui/account/sessions".to_string()),
535    );
536    context.insert(
537        "sessions_target".to_string(),
538        Value::String("#account-sessions".to_string()),
539    );
540    Ok(())
541}
542
543/// What `GET /api/mfa` answers, for the template.
544async fn status(state: &AdminState, user: &AdminUser) -> Result<Value, PageError> {
545    let remaining = mfa::recovery_codes_remaining(user.id, state.database.clone()).await?;
546    Ok(json!({
547        "totpEnabled": user.has_totp(),
548        "enrolmentPending": user.has_pending_totp(),
549        "recoveryCodesRemaining": remaining,
550    }))
551}
552
553/// Everything `account/_card.html` reads.
554///
555/// A fragment is rendered standalone, so it cannot inherit the `hx-headers` on
556/// `<body>` — the `csrf_token` has to be inserted by hand or every control in
557/// the swapped fragment answers `403`. Same rule as `pages::accounts::card`.
558async fn card_context(
559    state: &AdminState,
560    user: &AdminUser,
561    csrf_token: &str,
562) -> Result<Map<String, Value>, PageError> {
563    let mut context = Map::new();
564    context.insert(
565        "csrf_token".to_string(),
566        Value::String(csrf_token.to_string()),
567    );
568    context.insert(
569        "user".to_string(),
570        crate::admin::render_admin_user_json(user),
571    );
572    context.insert("mfa".to_string(), status(state, user).await?);
573    context.insert(
574        "require_mfa".to_string(),
575        Value::Bool(state.config.admin.require_mfa),
576    );
577    context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
578    Ok(context)
579}
580
581// No `not_found` here, unlike every sibling in this directory: none of these
582// routes takes an id. There is exactly one account this page can be about, and
583// the session names it.