Skip to main content

acme_proxy_admin/webadmin/pages/
session.rs

1//! `/ui/login` and `/ui/logout` — the sign-in page and its counterpart.
2//!
3//! The only page routes reachable without a session, and the only ones that use
4//! a plain HTML form rather than htmx: there is no CSRF token to send until a
5//! session exists, and signing in should work before a byte of JavaScript has
6//! loaded. What protects the route instead is `check_origin`, run inside
7//! [`crate::webadmin::handlers::session::sign_in`] — the same gate, the same
8//! function, as `POST /api/session`.
9
10use axum::Form;
11use axum::extract::{Query, State};
12use axum::http::{HeaderMap, StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use serde_json::{Map, Value};
15
16use crate::admin::mfa;
17use crate::webadmin::AdminState;
18use crate::webadmin::handlers::Caller;
19use crate::webadmin::handlers::session::apply_logout;
20use crate::webadmin::handlers::session::{
21    LoginRequest, LogoutQuery, MfaRequest, finish_enrolment, finish_mfa, sign_in,
22};
23use crate::webadmin::pages::auth::{PageMfaPending, PageMfaSubmit, PageSelfServiceWrite};
24use crate::webadmin::pages::error::{LOGIN_PATH, PageError, redirect};
25use crate::webadmin::pages::templates;
26use crate::webadmin::session::{AdminClientIp, MfaStep, PendingMfa, clearing_cookie};
27
28/// Where a successful sign-in lands.
29const PANEL_PATH: &str = "/ui/";
30
31/// Where a sign-in that still owes a second factor lands.
32pub(crate) const MFA_PATH: &str = "/ui/login/mfa";
33
34/// `GET /ui/login` — the sign-in form.
35///
36/// Rendered unconditionally, including for someone who already has a live
37/// session: checking would mean a second session-resolution path beside
38/// `resolve_session`, and the whole cost of not checking is that a signed-in
39/// operator who navigates here sees a form.
40pub async fn get_login(State(state): State<AdminState>) -> Result<Response, PageError> {
41    Ok(page(&state, None, None)?.into_response())
42}
43
44/// `POST /ui/login` — exchange a username and password for a session cookie.
45///
46/// A form body where `POST /api/session` takes JSON; everything that makes a
47/// sign-in safe is in [`sign_in`] and shared between them.
48pub async fn post_login(
49    State(state): State<AdminState>,
50    AdminClientIp(client): AdminClientIp,
51    headers: HeaderMap,
52    Form(credentials): Form<LoginRequest>,
53) -> Result<Response, PageError> {
54    match sign_in(&state, client, &headers, &credentials).await {
55        // A pending sign-in goes to the challenge page instead of the panel;
56        // everything else about the answer, cookie included, is the same.
57        Ok(signed_in) => Ok((
58            StatusCode::SEE_OTHER,
59            [
60                (
61                    header::LOCATION,
62                    if signed_in.pending.is_some() {
63                        MFA_PATH.to_string()
64                    } else {
65                        PANEL_PATH.to_string()
66                    },
67                ),
68                (header::SET_COOKIE, signed_in.cookie),
69            ],
70        )
71            .into_response()),
72        // Re-rendered rather than redirected, so the browser keeps the typed
73        // username and the reason is visible. The status is the real one --
74        // `401` for a refused credential, `429` for the limiter -- because a
75        // sign-in page answering `200` to a failed attempt is a lie a script
76        // would believe.
77        Err(error) => {
78            let status = error.status;
79            let flash = super::flash_error(error.code, error.message);
80            Ok((
81                status,
82                page(&state, Some(flash), Some(&credentials.username))?,
83            )
84                .into_response())
85        }
86    }
87}
88
89/// `GET /ui/login/mfa` — the second half of signing in.
90///
91/// Two shapes behind one URL, chosen off `step`: prove a code, or -- when
92/// `admin.require_mfa` is on and this operator has no factor -- set one up
93/// first. Both are the same half-authenticated session, and only
94/// `user.has_totp()` tells them apart.
95pub async fn get_login_mfa(
96    State(state): State<AdminState>,
97    session: PageMfaPending,
98) -> Result<Response, PageError> {
99    Ok(challenge(&state, session.pending, None)
100        .await?
101        .into_response())
102}
103
104/// `POST /ui/login/mfa` — finish the sign-in with a code.
105///
106/// A plain form, like `/ui/login` and for the same reason, so there is no CSRF
107/// token; [`crate::webadmin::session::PendingMfaSubmit`] runs the origin gate in
108/// its place.
109pub async fn post_login_mfa(
110    State(state): State<AdminState>,
111    AdminClientIp(client): AdminClientIp,
112    request_context: acme_proxy_core::audit::RequestContext,
113    session: PageMfaSubmit,
114    Form(body): Form<MfaRequest>,
115) -> Result<Response, PageError> {
116    // Under `require_mfa`, an operator with no factor finishes their login by
117    // *setting one up* rather than by proving one — so this same URL confirms an
118    // enrolment. Which of the two is `step`, and nothing else.
119    if session.pending.step == MfaStep::Enrol {
120        return confirm_enrolment(
121            &state,
122            client,
123            &request_context,
124            session.pending,
125            &body.code,
126        )
127        .await;
128    }
129
130    // Kept for the re-render: `finish_mfa` consumes the pending session.
131    let step = session.pending.step;
132    let expires_at = session.pending.session.expires_at;
133
134    match finish_mfa(&state, client, session.pending, &body.code).await {
135        Ok(signed_in) => Ok((
136            StatusCode::SEE_OTHER,
137            [
138                (header::LOCATION, PANEL_PATH.to_string()),
139                // The rotated cookie. Without setting it here the browser would
140                // keep the pending token, which `promote` has already deleted.
141                (header::SET_COOKIE, signed_in.cookie),
142            ],
143        )
144            .into_response()),
145        // Re-rendered at the real status, exactly as `post_login` does.
146        Err(error) => {
147            let status = error.status;
148            let flash = super::flash_error(error.code, error.message);
149            let mut context = Map::new();
150            context.insert("step".to_string(), Value::String(step.as_str().to_string()));
151            context.insert(
152                "expiresAt".to_string(),
153                Value::String(acme_proxy_core::datetime::rfc3339(expires_at)),
154            );
155            context.insert("flash".to_string(), flash);
156            Ok((status, render_challenge(&state, context)?).into_response())
157        }
158    }
159}
160
161/// The `enrol` half of `POST /ui/login/mfa`.
162///
163/// Answers with the recovery codes rather than a `303`, and that is the point:
164/// they exist for exactly one moment and a redirect would spend it. The rotated
165/// cookie rides along, so the "Continue" link on that page is already an
166/// authenticated navigation.
167async fn confirm_enrolment(
168    state: &AdminState,
169    client: Option<std::net::IpAddr>,
170    request_context: &acme_proxy_core::audit::RequestContext,
171    pending: PendingMfa,
172    code: &str,
173) -> Result<Response, PageError> {
174    let mut user = pending.user;
175    let keep = pending.session.token_hash.clone();
176
177    let Some(codes) =
178        mfa::confirm_totp_enrolment(&mut user, code, Some(&keep), state.database.clone()).await?
179    else {
180        let mut context = Map::new();
181        context.insert("step".to_string(), Value::String("enrol".to_string()));
182        context.insert(
183            "flash".to_string(),
184            super::flash_error("bad_request", "That code did not match. Try the next one."),
185        );
186        context.insert(
187            "enrolment".to_string(),
188            enrolment_context(state, &mut user).await?,
189        );
190        return Ok((StatusCode::UNAUTHORIZED, render_challenge(state, context)?).into_response());
191    };
192
193    // Both halves, through the one call that cannot write only one of them:
194    // this path notified and left no audit row, where its `/api` twin
195    // (`handlers::mfa::confirm_totp`) wrote both.
196    state
197        .record_credential_change(
198            request_context,
199            &user.username,
200            &user,
201            crate::webadmin::CredentialChange::SecondFactorEnabled,
202            true,
203            client,
204        )
205        .await;
206
207    // Completes the login, so it goes through the same function the API side
208    // does -- promotion plus `mark_logged_in`, `record_success` and the
209    // `admin_login_succeeded` line. Doing it by hand here is how the two front
210    // ends drifted the first time.
211    let (_, cookie) = finish_enrolment(
212        state,
213        client,
214        &mut user,
215        &pending.session.token_hash,
216        pending.session.user_agent.clone(),
217    )
218    .await?;
219
220    let mut context = Map::new();
221    context.insert("recovery_codes".to_string(), serde_json::json!(codes));
222    let body = templates::render(
223        &state.templates,
224        "mfa/enrolled.html",
225        minijinja::Value::from_serialize(Value::Object(context)),
226    )?;
227
228    let mut response = (StatusCode::OK, body).into_response();
229    if let Ok(value) = header::HeaderValue::from_str(&cookie) {
230        response.headers_mut().insert(header::SET_COOKIE, value);
231    }
232    Ok(response)
233}
234
235/// `POST /ui/logout[?all=true]` — sign out here, or everywhere.
236pub async fn post_logout(
237    State(state): State<AdminState>,
238    Query(query): Query<LogoutQuery>,
239    session: PageSelfServiceWrite,
240    request_context: acme_proxy_core::audit::RequestContext,
241) -> Result<Response, PageError> {
242    apply_logout(
243        &state,
244        &Caller::ui(&session.auth, &request_context),
245        query.all,
246    )
247    .await?;
248
249    // The redirect and the cookie together: leaving the cookie behind would
250    // send the browser to the sign-in page still carrying a token the server
251    // has already forgotten.
252    let mut response = redirect(LOGIN_PATH, session.hx);
253    if let Ok(value) = header::HeaderValue::from_str(&clearing_cookie()) {
254        response.headers_mut().insert(header::SET_COOKIE, value);
255    }
256    Ok(response)
257}
258
259/// Renders the sign-in page, optionally with a banner and a username to keep.
260fn page(
261    state: &AdminState,
262    flash: Option<Value>,
263    username: Option<&str>,
264) -> Result<axum::response::Html<String>, PageError> {
265    let mut context = Map::new();
266    if let Some(flash) = flash {
267        context.insert("flash".to_string(), flash);
268    }
269    if let Some(username) = username {
270        context.insert("username".to_string(), Value::String(username.to_string()));
271    }
272    templates::render(
273        &state.templates,
274        "login.html",
275        minijinja::Value::from_serialize(Value::Object(context)),
276    )
277}
278
279/// Renders the challenge page for a pending session, optionally with a banner.
280///
281/// On the `enrol` step this also mints (or resumes) the pending secret, since
282/// there is nothing to show otherwise. Resuming is what stops a page reload
283/// handing the operator a different secret from the one they have just scanned.
284async fn challenge(
285    state: &AdminState,
286    pending: PendingMfa,
287    flash: Option<Value>,
288) -> Result<axum::response::Html<String>, PageError> {
289    let mut context = Map::new();
290    context.insert(
291        "step".to_string(),
292        Value::String(pending.step.as_str().to_string()),
293    );
294    context.insert(
295        "expiresAt".to_string(),
296        Value::String(acme_proxy_core::datetime::rfc3339(
297            pending.session.expires_at,
298        )),
299    );
300    if let Some(flash) = flash {
301        context.insert("flash".to_string(), flash);
302    }
303    if pending.step == MfaStep::Enrol {
304        let mut user = pending.user;
305        context.insert(
306            "enrolment".to_string(),
307            enrolment_context(state, &mut user).await?,
308        );
309    }
310    render_challenge(state, context)
311}
312
313/// The `enrolment` object `mfa/_setup.html` reads.
314async fn enrolment_context(
315    state: &AdminState,
316    user: &mut acme_proxy_store::admin_user::AdminUser,
317) -> Result<Value, PageError> {
318    let enrolment = mfa::resume_or_begin_totp_enrolment(
319        user,
320        &state.config.admin.base_url,
321        state.database.clone(),
322    )
323    .await?;
324
325    Ok(serde_json::json!({
326        "secret": enrolment.secret_base32,
327        "uri": enrolment.uri,
328        "algorithm": "SHA1",
329        "digits": crate::admin::totp::DIGITS,
330        "period": crate::admin::totp::PERIOD_SECONDS,
331    }))
332}
333
334fn render_challenge(
335    state: &AdminState,
336    context: Map<String, Value>,
337) -> Result<axum::response::Html<String>, PageError> {
338    templates::render(
339        &state.templates,
340        "mfa/challenge.html",
341        minijinja::Value::from_serialize(Value::Object(context)),
342    )
343}