acme-proxy-admin 0.6.1

The operation layer and web admin panel of acme-proxy (internal crate, no semver promise)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
//! `/ui/account` — the operator's own page: password, second factor, and
//! their own live sessions.
//!
//! Everything here acts on whoever is holding the cookie, which is why no path
//! carries an id — the one apparent exception, `/ui/account/sessions/{id}`,
//! still names only *which session*, never a different account. Managing
//! *another* operator is `pages::operators`, not this module: the two are kept
//! apart because every route there sits behind a password re-entry
//! (`check_step_up`) this operator's own actions never need. `create`/`passwd`
//! stay a shell command on the host either way — this panel has no sign-up
//! page, and minting a credential is where that line is drawn.

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Map, Value, json};

use crate::admin;
use crate::admin::{mfa, totp};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::Caller;
use crate::webadmin::handlers::account::apply_revoke_own_session;
use crate::webadmin::handlers::mfa::verify_current_password;
use crate::webadmin::handlers::paging::PageParams;
use crate::webadmin::pages::auth::{PageEnrolWrite, PageSelfServiceWrite, PageSession};
use crate::webadmin::pages::error::PageError;
use crate::webadmin::pages::{chrome, respond, respond_fragment};
use crate::webadmin::session::{AdminClientIp, clearing_cookie};
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_user::AdminUser;

/// The form of `POST /ui/account/mfa/totp/confirm`, the `/ui` twin of
/// [`crate::webadmin::handlers::mfa::ConfirmRequest`].
#[derive(Debug, Deserialize)]
pub struct ConfirmForm {
    pub code: String,
}

/// The `/ui` twin of [`crate::webadmin::handlers::mfa::StepUpRequest`]: the
/// password the card's own field collects, pulled in by `hx-include`.
#[derive(Debug, Default, Deserialize)]
pub struct StepUpForm {
    #[serde(default)]
    pub password: String,
}

/// [`check_step_up`] with the refusal rendered as this card's banner.
///
/// The module's rule -- "the row's state is a banner, the server's problem is a
/// page" -- puts a wrong password on the banner side: the session is live and
/// the page is the right page, only this one action was refused. The same now
/// holds for the rate limit `check_step_up` applies, which is why the status and
/// the wording are taken from the error rather than hardcoded: a lockout renders
/// at 429 and says how long to wait, exactly as `post_login` re-renders its own
/// refusals at their real status.
async fn refuse_on_the_card(
    state: &AdminState,
    user: &AdminUser,
    csrf_token: &str,
    error: &AdminError,
) -> Result<Response, PageError> {
    // `refuse_with_card` keeps this page's own wording for the wrong-password
    // case (`AdminError`'s is "invalid username or password", a script's answer
    // to a sign-in, naming a field this card does not have) and carries every
    // other refusal's message through — the rate limit, which says how long to
    // wait, and the two the shared actions raise. It also keeps the error's
    // *headers*, which a rebuilt response lost: a `429` without its
    // `Retry-After`.
    let context = card_context(state, user, csrf_token).await?;
    super::refuse_with_card(state, "account/_mfa.html", context, error)
}

/// `GET /ui/account` — the second-factor status card, the password card, and
/// this operator's own live sessions.
pub async fn get_account(
    State(state): State<AdminState>,
    session: PageSession,
) -> Result<Response, PageError> {
    let mut context = chrome(&session, "account", "Your account");
    context.insert("mfa".to_string(), status(&state, &session.auth.user).await?);
    context.insert(
        "require_mfa".to_string(),
        Value::Bool(state.config.admin.require_mfa),
    );
    context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
    context.insert(
        "min_password_length".to_string(),
        json!(crate::admin::password::MIN_PASSWORD_LEN),
    );
    insert_own_sessions(&mut context, &state, &session.auth).await?;

    Ok(respond(
        &state,
        session.hx,
        "account/index.html",
        "account/_mfa.html",
        context,
    )?
    .into_response())
}

/// `POST /ui/account/mfa/totp` — begin (or resume) an enrolment.
///
/// Resumes rather than restarts when one is already pending: an operator who
/// reloads after scanning the secret into an app must not be handed a different
/// one.
///
/// Takes the account password when a factor already exists — the card's own
/// field, pulled in by `hx-include`. The check itself is the shared action's,
/// which `/api` makes too.
pub async fn begin_totp(
    State(state): State<AdminState>,
    AdminClientIp(client): AdminClientIp,
    session: PageEnrolWrite,
    axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
    let mut user = session.enrol.user;
    let enrolment = match crate::webadmin::handlers::mfa::begin_totp_for(
        &state,
        &mut user,
        &body.password,
        client,
    )
    .await
    {
        Ok(enrolment) => enrolment,
        Err(error) => {
            return refuse_on_the_card(&state, &user, &session.enrol.session.csrf_token, &error)
                .await;
        }
    };

    let mut context = Map::new();
    context.insert(
        "csrf_token".to_string(),
        Value::String(session.enrol.session.csrf_token.clone()),
    );
    context.insert(
        "enrolment".to_string(),
        json!({
            "secret": enrolment.secret_base32,
            "uri": enrolment.uri,
            "algorithm": "SHA1",
            "digits": totp::DIGITS,
            "period": totp::PERIOD_SECONDS,
        }),
    );

    Ok(respond_fragment(&state, "account/_enrol.html", context)?.into_response())
}

/// `POST /ui/account/mfa/totp/confirm` — prove a code, and receive the recovery
/// codes once.
///
/// A wrong code is a banner on the enrolment step, not an error page: the row's
/// state is a banner, the server's problem is a page.
pub async fn confirm_totp(
    State(state): State<AdminState>,
    AdminClientIp(client): AdminClientIp,
    session: PageEnrolWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    axum::Form(body): axum::Form<ConfirmForm>,
) -> Result<Response, PageError> {
    let mut user = session.enrol.user;
    let keep = session.enrol.session.token_hash.clone();

    let Some(codes) = crate::webadmin::handlers::mfa::confirm_totp_for(
        &state,
        &request_context,
        &mut user,
        &body.code,
        &keep,
        client,
    )
    .await?
    else {
        // Re-render the enrolment step with the same secret still pending, so
        // the operator can simply try the next code their app shows.
        let enrolment = mfa::resume_or_begin_totp_enrolment(
            &mut user,
            &state.config.admin.base_url,
            state.database.clone(),
        )
        .await?;

        let mut context = Map::new();
        context.insert(
            "csrf_token".to_string(),
            Value::String(session.enrol.session.csrf_token.clone()),
        );
        context.insert(
            "enrolment".to_string(),
            json!({
                "secret": enrolment.secret_base32,
                "uri": enrolment.uri,
                "algorithm": "SHA1",
                "digits": totp::DIGITS,
                "period": totp::PERIOD_SECONDS,
            }),
        );
        context.insert(
            "flash".to_string(),
            super::flash_error("bad_request", "That code did not match. Try the next one."),
        );

        return Ok((
            StatusCode::BAD_REQUEST,
            respond_fragment(&state, "account/_enrol.html", context)?,
        )
            .into_response());
    };

    let mut context = card_context(&state, &user, &session.enrol.session.csrf_token).await?;
    context.insert("recovery_codes".to_string(), json!(codes));
    Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
}

/// `POST /ui/account/mfa/totp/disable` — turn the factor off.
///
/// Takes the account password, as the shared action requires: this is the most
/// consequential thing a stolen cookie could do here.
pub async fn disable_totp(
    State(state): State<AdminState>,
    AdminClientIp(client): AdminClientIp,
    session: PageSelfServiceWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
    let mut user = session.auth.user;
    // A banner, not a page: every refusal here — the server requiring a factor,
    // a wrong password, a rate limit — is about this card's own state.
    if let Err(error) = crate::webadmin::handlers::mfa::disable_totp_for(
        &state,
        &request_context,
        &mut user,
        &body.password,
        &session.auth.session.token_hash,
        client,
    )
    .await
    {
        return refuse_on_the_card(&state, &user, &session.auth.session.csrf_token, &error).await;
    }

    let mut context = card_context(&state, &user, &session.auth.session.csrf_token).await?;
    context.insert(
        "flash".to_string(),
        super::flash(
            "warn",
            "Two-factor authentication is off. Your recovery codes were destroyed \
             and every other session of yours was signed out.",
        ),
    );
    Ok(respond_fragment(&state, "account/_mfa.html", context)?.into_response())
}

/// `POST /ui/account/mfa/recovery-codes` — mint a fresh set, **shown once**.
///
/// Takes the account password, as the shared action requires: superseding the
/// set the rightful operator would recover with is the same lockout as
/// replacing the factor itself.
pub async fn regenerate_recovery_codes(
    State(state): State<AdminState>,
    AdminClientIp(client): AdminClientIp,
    session: PageSelfServiceWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    axum::Form(body): axum::Form<StepUpForm>,
) -> Result<Response, PageError> {
    let codes = match crate::webadmin::handlers::mfa::regenerate_recovery_codes_for(
        &state,
        &request_context,
        &session.auth.user,
        &body.password,
        client,
    )
    .await
    {
        Ok(codes) => codes,
        Err(error) => {
            return refuse_on_the_card(
                &state,
                &session.auth.user,
                &session.auth.session.csrf_token,
                &error,
            )
            .await;
        }
    };

    let mut context =
        card_context(&state, &session.auth.user, &session.auth.session.csrf_token).await?;
    context.insert("recovery_codes".to_string(), json!(codes));

    Ok(respond_fragment(&state, "account/_codes.html", context)?.into_response())
}

/// The form of `POST /ui/account/password`, the `/ui` twin of
/// [`crate::webadmin::handlers::account::ChangePasswordRequest`].
#[derive(Debug, Deserialize)]
pub struct ChangePasswordForm {
    pub current_password: String,
    pub new_password: String,
}

/// The notification-address form. A blank `contact` clears the address.
#[derive(Debug, Default, Deserialize)]
pub struct ContactForm {
    #[serde(default)]
    pub current_password: String,
    #[serde(default)]
    pub contact: String,
}

/// Everything `account/_contact.html` reads: the token (a fragment rendered
/// standalone cannot inherit `<body>`'s `hx-headers`) and the operator.
/// `typed` is what the form last sent, echoed back on a refusal so a mistyped
/// address can be corrected rather than retyped.
fn contact_card_context(
    csrf_token: &str,
    user: &AdminUser,
    typed: Option<&str>,
) -> Map<String, Value> {
    let mut context = Map::new();
    context.insert(
        "csrf_token".to_string(),
        Value::String(csrf_token.to_string()),
    );
    context.insert(
        "user".to_string(),
        crate::admin::render_admin_user_json(user),
    );
    if let Some(typed) = typed {
        context.insert(
            "contact_input".to_string(),
            Value::String(typed.to_string()),
        );
    }
    context
}

/// `POST /ui/account/contact` — set or clear the address this operator's own
/// security notifications go to.
///
/// The `/ui` twin of [`crate::webadmin::handlers::account::change_contact`],
/// which says why the password is asked for. A wrong password, a rate limit and
/// an address that is not a mailbox are all this card's own banner.
pub async fn change_contact(
    State(state): State<AdminState>,
    AdminClientIp(client): AdminClientIp,
    session: PageSelfServiceWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    axum::Form(form): axum::Form<ContactForm>,
) -> Result<Response, PageError> {
    let caller = session.auth.user.clone();
    let csrf_token = session.auth.session.csrf_token.clone();

    if let Err(error) =
        verify_current_password(&caller, &form.current_password, client, &state.logins).await
    {
        return super::refuse_with_card(
            &state,
            "account/_contact.html",
            contact_card_context(&csrf_token, &caller, Some(&form.contact)),
            &error,
        );
    }

    let mut target = caller.clone();
    if let Err(error) = crate::webadmin::handlers::operators::apply_contact_change(
        &state,
        &caller,
        &mut target,
        Some(form.contact.as_str()),
        client,
        &request_context,
        "ui",
    )
    .await
    {
        if error.status.is_server_error() {
            return Err(error.into());
        }
        return super::refuse_with_card(
            &state,
            "account/_contact.html",
            contact_card_context(&csrf_token, &caller, Some(&form.contact)),
            &error,
        );
    }

    let mut context = contact_card_context(&csrf_token, &target, None);
    let banner = match &target.contact_email {
        Some(address) => super::flash("ok", format!("Security notifications now go to {address}.")),
        None => super::flash(
            "warn",
            "No address is on file: security notifications cannot reach you.",
        ),
    };
    context.insert("flash".to_string(), banner);
    Ok(respond_fragment(&state, "account/_contact.html", context)?.into_response())
}

/// Everything `account/_password_card.html` reads. Unlike [`card_context`]
/// there is no second-factor state to report, but the `csrf_token` rule is
/// the same: a fragment rendered standalone cannot inherit `<body>`'s
/// `hx-headers`, so this card's own form carries it explicitly.
fn password_card_context(csrf_token: &str) -> Map<String, Value> {
    let mut context = Map::new();
    context.insert(
        "csrf_token".to_string(),
        Value::String(csrf_token.to_string()),
    );
    // A client-side hint only -- `check_password_policy` is what actually
    // enforces it, so tying the two together is about the message staying
    // true rather than about anything security-relevant.
    context.insert(
        "min_password_length".to_string(),
        json!(crate::admin::password::MIN_PASSWORD_LEN),
    );
    context
}

/// `POST /ui/account/password` — change this operator's own password.
///
/// Takes the current password and the new one; on success every *other*
/// session of this operator is revoked and the one making this request stays
/// signed in. The change itself is
/// `handlers::account::change_own_password_for`, which `/api` calls too.
pub async fn change_password(
    State(state): State<AdminState>,
    AdminClientIp(client): AdminClientIp,
    session: PageSelfServiceWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    axum::Form(body): axum::Form<ChangePasswordForm>,
) -> Result<Response, PageError> {
    let mut user = session.auth.user;
    let csrf_token = session.auth.session.csrf_token.clone();
    let mut fragment_context = password_card_context(&csrf_token);

    // Every refusal is a banner on this card, and its wording is the API's:
    // one change, one set of sentences, whichever surface asked.
    if let Err(error) = crate::webadmin::handlers::account::change_own_password_for(
        &state,
        &request_context,
        &mut user,
        &body.current_password,
        &body.new_password,
        &session.auth.session.token_hash,
        client,
    )
    .await
    {
        return super::refuse_with_card(&state, "account/_password.html", fragment_context, &error);
    }

    fragment_context.insert(
        "flash".to_string(),
        super::flash(
            "ok",
            "Your password was changed. Every other session of yours was signed out.",
        ),
    );
    Ok(respond_fragment(&state, "account/_password.html", fragment_context)?.into_response())
}

/// `POST /ui/account/sessions/{id}/revoke` — end one of this operator's own
/// sessions.
///
/// No step-up: the same trust level as `/ui/logout` (sign out here, or
/// everywhere), not the operators surface's "act on someone else's account".
/// Revoking the session making *this* request is not a special case to guard
/// against — it behaves exactly like `/ui/logout` without `?all=true`, landing
/// back on the sign-in page with the cookie cleared, rather than re-rendering a
/// fragment for a session that no longer exists.
pub async fn revoke_own_session(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    session: PageSelfServiceWrite,
    request_context: acme_proxy_core::audit::RequestContext,
) -> Result<Response, PageError> {
    let was_current =
        apply_revoke_own_session(&state, &Caller::ui(&session.auth, &request_context), &id).await?;

    if was_current {
        let mut response = crate::webadmin::pages::error::redirect(
            crate::webadmin::pages::error::LOGIN_PATH,
            session.hx,
        );
        if let Ok(value) = axum::http::HeaderValue::from_str(&clearing_cookie()) {
            response
                .headers_mut()
                .insert(axum::http::header::SET_COOKIE, value);
        }
        return Ok(response);
    }

    let mut context = Map::new();
    context.insert(
        "csrf_token".to_string(),
        Value::String(session.auth.session.csrf_token.clone()),
    );
    insert_own_sessions(&mut context, &state, &session.auth).await?;
    context.insert("flash".to_string(), super::flash("ok", "Session revoked."));
    Ok(respond_fragment(&state, "account/_sessions.html", context)?.into_response())
}

/// Everything `partials/_sessions_table.html` reads for this operator's own
/// sessions -- newest first, each marked whether it is the one making the
/// current request (see [`admin::render_admin_session_detail_json`]), and no
/// step-up prefix: revoking one's own session sits at "sign out" trust level,
/// not the operators surface's.
///
/// One page's worth: an operator accumulates a handful of browser sessions,
/// never enough to need the pager the CLI's unbounded `admin session list`
/// does.
async fn insert_own_sessions(
    context: &mut Map<String, Value>,
    state: &AdminState,
    auth: &crate::webadmin::session::Authenticated,
) -> Result<(), PageError> {
    let page = PageParams::default().resolve(&state.config);
    let (sessions, _total) =
        AdminSession::search(Some(auth.user.id), page.limit, page.offset, &state.database).await?;
    let rows: Vec<Value> = sessions
        .iter()
        .map(|s| admin::render_admin_session_detail_json(s, &auth.session.token_hash))
        .collect();

    context.insert("sessions".to_string(), Value::Array(rows));
    context.insert(
        "sessions_revoke_prefix".to_string(),
        Value::String("/ui/account/sessions".to_string()),
    );
    context.insert(
        "sessions_target".to_string(),
        Value::String("#account-sessions".to_string()),
    );
    Ok(())
}

/// What `GET /api/mfa` answers, for the template.
async fn status(state: &AdminState, user: &AdminUser) -> Result<Value, PageError> {
    let remaining = mfa::recovery_codes_remaining(user.id, state.database.clone()).await?;
    Ok(json!({
        "totpEnabled": user.has_totp(),
        "enrolmentPending": user.has_pending_totp(),
        "recoveryCodesRemaining": remaining,
    }))
}

/// Everything `account/_card.html` reads.
///
/// A fragment is rendered standalone, so it cannot inherit the `hx-headers` on
/// `<body>` — the `csrf_token` has to be inserted by hand or every control in
/// the swapped fragment answers `403`. Same rule as `pages::accounts::card`.
async fn card_context(
    state: &AdminState,
    user: &AdminUser,
    csrf_token: &str,
) -> Result<Map<String, Value>, PageError> {
    let mut context = Map::new();
    context.insert(
        "csrf_token".to_string(),
        Value::String(csrf_token.to_string()),
    );
    context.insert(
        "user".to_string(),
        crate::admin::render_admin_user_json(user),
    );
    context.insert("mfa".to_string(), status(state, user).await?);
    context.insert(
        "require_mfa".to_string(),
        Value::Bool(state.config.admin.require_mfa),
    );
    context.insert("period".to_string(), json!(totp::PERIOD_SECONDS));
    Ok(context)
}

// No `not_found` here, unlike every sibling in this directory: none of these
// routes takes an id. There is exactly one account this page can be about, and
// the session names it.