Skip to main content

acme_proxy_admin/webadmin/handlers/
account.rs

1//! `/api/account` — the operator's own account, distinct from `/api/mfa`'s
2//! second factor: the password, and (see `sessions`) the caller's own live
3//! sessions.
4//!
5//! No id on `/api/account/password` for the same reason `/api/mfa` has none:
6//! there is exactly one account this session can be about. `/api/account/
7//! sessions/{id}` is the one exception, and it is not really one — `{id}` names
8//! *which session*, not which account; the account is still only ever "this
9//! one", which is what keeps this module distinct from `/api/operators`
10//! (`handlers::operators`), where `{username}` genuinely selects a target.
11
12use axum::Json;
13use axum::extract::{Path, Query, State};
14use axum::http::StatusCode;
15use axum::response::{IntoResponse, Response};
16use serde::Deserialize;
17use serde_json::Value;
18
19use crate::admin;
20use crate::admin::password::PasswordContext;
21use crate::admin::users::{self, UserError};
22use crate::webadmin::AdminState;
23use crate::webadmin::error::AdminError;
24use crate::webadmin::handlers::Caller;
25use crate::webadmin::handlers::mfa::verify_current_password;
26use crate::webadmin::handlers::paging::{PageParams, page_envelope};
27use crate::webadmin::session::{AdminClientIp, Authenticated, SelfServiceWrite, clearing_cookie};
28use acme_proxy_store::admin_session::AdminSession;
29
30/// The body of `POST /api/account/password`. `current_password` is checked
31/// again here, whatever the session.
32#[derive(Debug, Deserialize)]
33pub struct ChangePasswordRequest {
34    pub current_password: String,
35    pub new_password: String,
36}
37
38/// The body of `POST /api/account/contact`. An absent, `null` or blank
39/// `contact` clears the address.
40#[derive(Debug, Default, Deserialize)]
41pub struct ChangeContactRequest {
42    #[serde(default)]
43    pub current_password: String,
44    #[serde(default)]
45    pub contact: Option<String>,
46}
47
48/// `POST /api/account/contact` — set or clear the address this operator's own
49/// security notifications go to.
50///
51/// The address is not a credential and no session is revoked, but the current
52/// password is still re-proved ([`verify_current_password`]): it is where the
53/// alarms go, so a stolen cookie that could change it silently would switch off
54/// the one signal that the cookie was stolen. For the same reason the address
55/// it replaces is told
56/// ([`acme_proxy_jobs::notify::AdminCredentialChange::ContactAddress`]).
57pub async fn change_contact(
58    State(state): State<AdminState>,
59    AdminClientIp(client): AdminClientIp,
60    SelfServiceWrite(auth): SelfServiceWrite,
61    request_context: acme_proxy_core::audit::RequestContext,
62    body: Option<Json<ChangeContactRequest>>,
63) -> Result<Response, AdminError> {
64    let body = body.unwrap_or_default();
65    let caller = auth.user;
66    verify_current_password(&caller, &body.current_password, client, &state.logins).await?;
67
68    let mut target = caller.clone();
69    super::operators::apply_contact_change(
70        &state,
71        &caller,
72        &mut target,
73        body.contact.as_deref(),
74        client,
75        &request_context,
76        "api",
77    )
78    .await?;
79    Ok(StatusCode::NO_CONTENT.into_response())
80}
81
82/// `POST /api/account/password` — change this operator's own password.
83///
84/// ASVS V6.2.3: takes the *current* password and verifies it
85/// ([`verify_current_password`]) before writing a new hash, unlike
86/// `acme-proxy admin user passwd` on the host, which already runs as the
87/// process trusted to rewrite the row. Every other session this operator
88/// holds is revoked ([`users::change_own_password`]); the one making this
89/// request survives, or the panel would sign its own operator out mid-edit.
90pub async fn change_password(
91    State(state): State<AdminState>,
92    AdminClientIp(client): AdminClientIp,
93    SelfServiceWrite(auth): SelfServiceWrite,
94    request_context: acme_proxy_core::audit::RequestContext,
95    Json(body): Json<ChangePasswordRequest>,
96) -> Result<Response, AdminError> {
97    let mut user = auth.user;
98    change_own_password_for(
99        &state,
100        &request_context,
101        &mut user,
102        &body.current_password,
103        &body.new_password,
104        &auth.session.token_hash,
105        client,
106    )
107    .await?;
108
109    Ok(StatusCode::NO_CONTENT.into_response())
110}
111
112/// The password change itself, the one function both front ends call: the
113/// current password, the policy, the write that revokes this operator's other
114/// sessions, and the notification and audit row that follow it.
115///
116/// What stays with each front end is the rendering — a `204`, or the password
117/// card with a banner. A refusal is an [`AdminError`] either way, so the policy
118/// message a script reads and the one a browser shows are the same sentence.
119pub(crate) async fn change_own_password_for(
120    state: &AdminState,
121    request: &acme_proxy_core::audit::RequestContext,
122    user: &mut acme_proxy_store::admin_user::AdminUser,
123    current_password: &str,
124    new_password: &str,
125    keep: &str,
126    client: Option<std::net::IpAddr>,
127) -> Result<(), AdminError> {
128    verify_current_password(user, current_password, client, &state.logins).await?;
129
130    let context = PasswordContext::from_config(&state.config, &user.username);
131    users::change_own_password(user, new_password, &context, keep, state.database.clone())
132        .await
133        .map_err(|error| match error {
134            // `change_own_password` never builds `InvalidContact`; it is here
135            // because the shared `UserError` carries it, and a 400 is what it
136            // would mean.
137            UserError::Policy(message) | UserError::InvalidContact(message) => {
138                AdminError::bad_request(message)
139            }
140            UserError::Database(_) | UserError::DuplicateUsername(_) => AdminError::internal(),
141        })?;
142
143    state
144        .record_credential_change(
145            request,
146            &user.username,
147            user,
148            crate::webadmin::CredentialChange::Password,
149            true,
150            client,
151        )
152        .await;
153    Ok(())
154}
155
156/// `GET /api/account/sessions?limit=&offset=` — this operator's own live
157/// sessions, newest first, over the same [`AdminSession::search`] `admin
158/// session list` uses. The literal "nothing in between" the panel had: the
159/// only other lever on this account was "sign out everywhere"
160/// (`DELETE /api/session?all=true`).
161///
162/// [`admin::render_admin_session_detail_json`] marks whichever row is the
163/// session making this request, so a caller can tell "sign out here" from
164/// "revoke an old one" without comparing hashes itself.
165pub async fn list_own_sessions(
166    State(state): State<AdminState>,
167    Query(params): Query<PageParams>,
168    auth: Authenticated,
169) -> Result<Json<Value>, AdminError> {
170    let page = params.resolve(&state.config);
171    let (sessions, total) =
172        AdminSession::search(Some(auth.user.id), page.limit, page.offset, &state.database).await?;
173    let items: Vec<Value> = sessions
174        .iter()
175        .map(|session| admin::render_admin_session_detail_json(session, &auth.session.token_hash))
176        .collect();
177    Ok(Json(page_envelope(items, total, page)))
178}
179
180/// `POST /api/account/sessions/{id}/revoke` — end one of this operator's own
181/// sessions.
182///
183/// No [`crate::webadmin::handlers::mfa::check_step_up`] here: this is the same
184/// trust level as `DELETE /api/session` (sign out here, or everywhere), not
185/// the operators surface's "act on someone else's account". `id` is resolved
186/// via [`AdminSession::find_by_user_and_fingerprint`] scoped to the caller's
187/// own `user_id`, so this route can never reach another operator's session —
188/// a wrong or foreign `id` is `404`, identically to one that never existed.
189///
190/// Revoking the session making *this* request is not a special case to guard
191/// against — it is the one-session form of signing out, so it clears the
192/// cookie exactly as `DELETE /api/session` (without `all`) does.
193pub async fn revoke_own_session(
194    State(state): State<AdminState>,
195    Path(id): Path<String>,
196    SelfServiceWrite(auth): SelfServiceWrite,
197    request_context: acme_proxy_core::audit::RequestContext,
198) -> Result<Response, AdminError> {
199    let was_current =
200        apply_revoke_own_session(&state, &Caller::api(&auth, &request_context), &id).await?;
201
202    if was_current {
203        return Ok((
204            StatusCode::NO_CONTENT,
205            [(axum::http::header::SET_COOKIE, clearing_cookie())],
206        )
207            .into_response());
208    }
209    Ok(StatusCode::NO_CONTENT.into_response())
210}
211
212/// Ends one of the caller's own sessions, found by fingerprint **within the
213/// caller's own `user_id`**, so a foreign id is `404` exactly like one that
214/// never existed. Answers whether it was the session making this request,
215/// which the front end then signs out of.
216pub(crate) async fn apply_revoke_own_session(
217    state: &AdminState,
218    caller: &Caller<'_>,
219    id: &str,
220) -> Result<bool, AdminError> {
221    let session =
222        AdminSession::find_by_user_and_fingerprint(caller.auth.user.id, id, &state.database)
223            .await?
224            .ok_or_else(|| session_not_found(id))?;
225    let was_current = session.token_hash == caller.auth.session.token_hash;
226    AdminSession::delete(&session.token_hash, &state.database).await?;
227
228    let scope = if was_current {
229        acme_proxy_jobs::auditor::admin::SessionScope::OwnCurrent
230    } else {
231        acme_proxy_jobs::auditor::admin::SessionScope::OwnOther
232    };
233    state
234        .record_admin_action(caller.request, caller.username(), |actor, ctx| {
235            acme_proxy_jobs::auditor::admin::session_revoked(actor, ctx, scope, 1)
236        })
237        .await;
238    tracing::info!(event = "admin_session_revoked",
239                   outcome = "success",
240                   surface = caller.surface,
241                   scope = "self",
242                   username = %caller.username(),
243                   session_fp = %id);
244    Ok(was_current)
245}
246
247fn session_not_found(id: &str) -> AdminError {
248    AdminError::not_found(crate::admin::subject::Subject::Session.missing(id))
249}