Skip to main content

renox_core/auth/
account.rs

1//! The account page (`Auth::account`) and password confirmation
2//! (`Routes::require_password_confirmed`).
3
4use std::sync::Arc;
5
6use axum::extract::{Extension, State};
7use axum::response::{IntoResponse, Redirect, Response};
8use serde::Deserialize;
9
10use super::events::{
11    AccountDeleted, OtherDevicesLoggedOut, PasswordChanged, ProfileUpdated, announce,
12};
13use super::module::{Settings, go, label, texts};
14use super::{User, change_password, logout_other_devices, verification};
15use crate::db::Model;
16use crate::i18n::Lang;
17use crate::validation::{Errors, Validate, ValidationError, Validator};
18use crate::{AppState, AuthUser, Htmx, Result, Routes, Session, View, context, view};
19
20/// When the password was last typed, in unix seconds.
21const CONFIRMED_AT: &str = "_password_confirmed_at";
22/// How long a password confirmation lasts, as in Laravel.
23pub(crate) const CONFIRM_FOR: u64 = 3 * 60 * 60;
24const CONFIRM_INTENDED: &str = "_password_confirm_intended";
25
26pub(super) fn routes() -> Routes {
27    Routes::new()
28        .get("/account", show)
29        .name("account.show")
30        .put("/account/profile", update_profile)
31        .name("account.profile")
32        .put("/account/password", update_password)
33        .name("account.password")
34        .post("/account/logout-others", logout_others)
35        .name("account.logout_others")
36        .delete("/account", destroy)
37        .name("account.destroy")
38        .require_auth()
39}
40
41fn unix_now() -> u64 {
42    crate::clock::unix_secs().max(0) as u64
43}
44
45/// Records that the user just typed their password.
46pub(crate) fn mark_confirmed(session: &Session) -> Result {
47    session.put(CONFIRMED_AT, unix_now())
48}
49
50/// Where to go once the password is confirmed: the page that asked, else `/`.
51pub(crate) fn confirmed_destination(session: &Session) -> String {
52    session
53        .pull::<String>(CONFIRM_INTENDED)
54        .filter(|path| crate::htmx::is_local_path(path))
55        .unwrap_or_else(|| "/".into())
56}
57
58/// Whether the password was typed in the last three hours.
59pub(crate) fn recently_confirmed(session: &Session) -> bool {
60    session
61        .get::<u64>(CONFIRMED_AT)
62        .is_some_and(|at| unix_now().saturating_sub(at) < CONFIRM_FOR)
63}
64
65/// Route guard: sends users who haven't typed their password lately to
66/// `/confirm-password`, then back.
67pub(crate) async fn require_password_confirmed(
68    req: axum::extract::Request,
69    next: axum::middleware::Next,
70) -> Response {
71    let Some(session) = req.extensions().get::<Session>().cloned() else {
72        return next.run(req).await;
73    };
74    if recently_confirmed(&session) {
75        return next.run(req).await;
76    }
77    // After confirming, go back: to this page for a GET; for a form that
78    // posts, puts or deletes, to the page the form was on (it can't be
79    // replayed), taken from `Referer` when it's this site's own path.
80    let back = if req.method() == axum::http::Method::GET {
81        req.uri().path_and_query().map(|p| p.as_str().to_owned())
82    } else {
83        crate::htmx::same_site_referer(req.headers())
84    };
85    if let Some(back) = back {
86        let _ = session.put(CONFIRM_INTENDED, back);
87    }
88    let confirm = req
89        .extensions()
90        .get::<AppState>()
91        .and_then(|state| state.url("password.confirm", &[]).ok())
92        .unwrap_or_else(|| "/confirm-password".into());
93    if crate::Htmx::from_headers(req.headers()).request {
94        return crate::HxRedirect(confirm).into_response();
95    }
96    Redirect::to(&confirm).into_response()
97}
98
99pub(super) async fn show_confirm(lang: Lang) -> View {
100    view(
101        "renox/auth/confirm-password.html",
102        context! { text => texts(&lang) },
103    )
104}
105
106#[derive(Deserialize)]
107pub(super) struct ConfirmForm {
108    password: String,
109}
110
111impl Validate for ConfirmForm {
112    fn rules(&self, v: &mut Validator) {
113        let password = label(v, "password");
114        v.field("password", &self.password)
115            .fallback_label(password)
116            .required();
117    }
118}
119
120/// Checks `password` against the user's, or answers with a validation
121/// error on `field`.
122async fn check_password(
123    user: &User,
124    password: &str,
125    field: &str,
126    lang: &Lang,
127) -> std::result::Result<(), crate::Error> {
128    if user.check_password(password).await {
129        return Ok(());
130    }
131    let template = crate::validation::template_for(Some(&lang.texts()), "current_password");
132    // The app's name for the field (`renox.validation.attributes.<field>`)
133    // when its lang file has one.
134    let name = lang
135        .texts()
136        .get(&format!("renox.validation.attributes.{field}"))
137        .cloned()
138        .unwrap_or_else(|| match field {
139            "current_password" => "current password".to_owned(),
140            _ => "password".to_owned(),
141        });
142    let mut errors = Errors::new();
143    errors.add(field, crate::validation::render(&template, &name, &[]));
144    Err(ValidationError::new(errors).into())
145}
146
147pub(super) async fn confirm(
148    user: AuthUser,
149    session: Session,
150    htmx: Htmx,
151    lang: Lang,
152    crate::validation::Valid(form): crate::validation::Valid<ConfirmForm>,
153) -> Result<Response> {
154    check_password(user.user(), &form.password, "password", &lang).await?;
155    mark_confirmed(&session)?;
156    Ok(go(&htmx, confirmed_destination(&session)))
157}
158
159/// A section another module adds to `/account` (`Registry::account_section`).
160pub(crate) struct AccountSection {
161    template: String,
162    pub(crate) order: i32,
163    data: SectionFn,
164}
165
166type SectionFn = Arc<
167    dyn Fn(
168            User,
169            AppState,
170        )
171            -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value>> + Send>>
172        + Send
173        + Sync,
174>;
175
176pub(crate) fn section<F, Fut>(template: &str, order: i32, data: F) -> AccountSection
177where
178    F: Fn(User, AppState) -> Fut + Send + Sync + 'static,
179    Fut: std::future::Future<Output = Result<serde_json::Value>> + Send + 'static,
180{
181    AccountSection {
182        template: template.to_owned(),
183        order,
184        data: Arc::new(move |user, state| Box::pin(data(user, state))),
185    }
186}
187
188async fn show(
189    Extension(settings): Extension<Arc<Settings>>,
190    State(state): State<AppState>,
191    user: AuthUser,
192    lang: Lang,
193) -> Result<View> {
194    // Read what each section needs first: the closures borrow nothing across
195    // the awaits, so the handler's future stays `Send`.
196    let registered: Vec<(String, SectionFn)> = state
197        .account_sections
198        .iter()
199        .map(|section| (section.template.clone(), section.data.clone()))
200        .collect();
201    let mut sections = Vec::with_capacity(registered.len());
202    for (template, data) in registered {
203        let data = data(user.user().clone(), state.clone()).await?;
204        sections.push(context! { template, data });
205    }
206    Ok(view(
207        "renox/auth/account.html",
208        context! {
209            text => texts(&lang),
210            user => user.user(),
211            has_password => user.has_password(),
212            verify_email => settings.verify_email,
213            sections,
214        },
215    ))
216}
217
218#[derive(Deserialize)]
219struct ProfileForm {
220    name: String,
221    email: String,
222}
223
224impl Validate for ProfileForm {
225    fn rules(&self, v: &mut Validator) {
226        let name = label(v, "name");
227        v.field("name", &self.name)
228            .fallback_label(name)
229            .required()
230            .max(255);
231        v.field("email", &super::user::normalize_email(&self.email))
232            .required()
233            .email()
234            .max(255);
235    }
236}
237
238async fn update_profile(
239    Extension(settings): Extension<Arc<Settings>>,
240    State(state): State<AppState>,
241    user: AuthUser,
242    session: Session,
243    htmx: Htmx,
244    lang: Lang,
245    req: axum::extract::Request,
246) -> Result<Response> {
247    let id = user.id;
248    let validated = crate::validation::extract::validate_request(
249        req,
250        &state,
251        move |form: &ProfileForm, _, v| {
252            v.field("email", &super::user::normalize_email(&form.email))
253                .unique("users", "email")
254                .ignore(id);
255        },
256    )
257    .await;
258    let form = match validated {
259        Ok((form, _)) => form,
260        Err(rejection) => return Ok(rejection),
261    };
262    let mut me = user.user().clone();
263    let email = super::user::normalize_email(&form.email);
264    let email_changed = email != me.email;
265    me.name = form.name.trim().to_owned();
266    if email_changed {
267        me.email = email;
268        if settings.verify_email {
269            me.email_verified_at = None;
270        }
271    }
272    me.save(&state.db).await?;
273    if email_changed && settings.verify_email {
274        verification::send_verification(&state, &me).await?;
275    }
276    let event = ProfileUpdated {
277        user_id: me.id,
278        email_changed,
279    };
280    announce(&state, event).await;
281    session.flash("status", &texts(&lang)["profile_saved"])?;
282    Ok(go(&htmx, state.url("account.show", &[])?))
283}
284
285#[derive(Deserialize)]
286struct PasswordForm {
287    /// Not on the form of a user without a password.
288    #[serde(default)]
289    current_password: String,
290    password: String,
291    password_confirmation: Option<String>,
292}
293
294impl Validate for PasswordForm {
295    fn rules(&self, _v: &mut Validator) {}
296}
297
298/// The password typed to confirm an action on the account page (none for
299/// a user without one).
300#[derive(Deserialize)]
301struct PasswordCheck {
302    #[serde(default)]
303    password: String,
304}
305
306impl Validate for PasswordCheck {
307    fn rules(&self, _v: &mut Validator) {}
308}
309
310/// A user without a password (a social login) proves who they are with a
311/// recent confirmation instead (logging in, or `/confirm-password` another
312/// way): without one, the browser goes to `/confirm-password` and comes
313/// back to the account page.
314fn needs_confirmation(
315    state: &AppState,
316    session: &Session,
317    user: &User,
318    htmx: &Htmx,
319) -> Option<Response> {
320    if user.has_password() || recently_confirmed(session) {
321        return None;
322    }
323    if let Ok(account) = state.url("account.show", &[]) {
324        let _ = session.put(CONFIRM_INTENDED, account);
325    }
326    let confirm = state
327        .url("password.confirm", &[])
328        .unwrap_or_else(|_| "/confirm-password".into());
329    Some(go(htmx, confirm))
330}
331
332/// Checks the typed password of a user who has one.
333async fn check_typed(user: &User, typed: &str, field: &str, lang: &Lang) -> Result {
334    if user.has_password() {
335        check_password(user, typed, field, lang).await?;
336    }
337    Ok(())
338}
339
340/// Validates a [`PasswordCheck`]: the password is required when the user has one.
341async fn password_check(
342    state: &AppState,
343    user: &User,
344    req: axum::extract::Request,
345) -> std::result::Result<PasswordCheck, Box<Response>> {
346    let has_password = user.has_password();
347    crate::validation::extract::validate_request(req, state, move |form: &PasswordCheck, _, v| {
348        if has_password {
349            let password = label(v, "password");
350            v.field("password", &form.password)
351                .fallback_label(password)
352                .required();
353        }
354    })
355    .await
356    .map(|(form, _)| form)
357    .map_err(Box::new)
358}
359
360async fn update_password(
361    Extension(settings): Extension<Arc<Settings>>,
362    State(state): State<AppState>,
363    user: AuthUser,
364    session: Session,
365    htmx: Htmx,
366    lang: Lang,
367    req: axum::extract::Request,
368) -> Result<Response> {
369    if let Some(confirm) = needs_confirmation(&state, &session, user.user(), &htmx) {
370        return Ok(confirm);
371    }
372    let policy = settings.password.clone();
373    let has_password = user.has_password();
374    let validated = crate::validation::extract::validate_request(
375        req,
376        &state,
377        move |form: &PasswordForm, _, v| {
378            if has_password {
379                let current = label(v, "current_password");
380                v.field("current_password", &form.current_password)
381                    .fallback_label(current)
382                    .required();
383            }
384            let password = label(v, "password");
385            v.field("password", &form.password)
386                .fallback_label(password)
387                .required()
388                .password(&policy)
389                .confirmed(&form.password_confirmation);
390        },
391    )
392    .await;
393    let form = match validated {
394        Ok((form, _)) => form,
395        Err(rejection) => return Ok(rejection),
396    };
397    check_typed(
398        user.user(),
399        &form.current_password,
400        "current_password",
401        &lang,
402    )
403    .await?;
404    let mut me = user.user().clone();
405    change_password(&state.db, &session, &mut me, &form.password).await?;
406    mark_confirmed(&session)?;
407    announce(&state, PasswordChanged { user_id: me.id }).await;
408    session.flash("status", &texts(&lang)["password_changed"])?;
409    Ok(go(&htmx, state.url("account.show", &[])?))
410}
411
412async fn logout_others(
413    State(state): State<AppState>,
414    user: AuthUser,
415    session: Session,
416    htmx: Htmx,
417    lang: Lang,
418    req: axum::extract::Request,
419) -> Result<Response> {
420    if let Some(confirm) = needs_confirmation(&state, &session, user.user(), &htmx) {
421        return Ok(confirm);
422    }
423    let form = match password_check(&state, user.user(), req).await {
424        Ok(form) => form,
425        Err(rejection) => return Ok(*rejection),
426    };
427    check_typed(user.user(), &form.password, "password", &lang).await?;
428    logout_other_devices(&state.db, &session, user.user()).await?;
429    announce(&state, OtherDevicesLoggedOut { user_id: user.id }).await;
430    session.flash("status", &texts(&lang)["other_devices_logged_out"])?;
431    Ok(go(&htmx, state.url("account.show", &[])?))
432}
433
434async fn destroy(
435    State(state): State<AppState>,
436    user: AuthUser,
437    session: Session,
438    htmx: Htmx,
439    lang: Lang,
440    req: axum::extract::Request,
441) -> Result<Response> {
442    if let Some(confirm) = needs_confirmation(&state, &session, user.user(), &htmx) {
443        return Ok(confirm);
444    }
445    let form = match password_check(&state, user.user(), req).await {
446        Ok(form) => form,
447        Err(rejection) => return Ok(*rejection),
448    };
449    check_typed(user.user(), &form.password, "password", &lang).await?;
450    user.delete_account(&state.db).await?;
451    session.flush();
452    let event = AccountDeleted {
453        user_id: user.id,
454        email: user.email.clone(),
455    };
456    announce(&state, event).await;
457    Ok(go(
458        &htmx,
459        state.url("home", &[]).unwrap_or_else(|_| "/".into()),
460    ))
461}
462
463impl User {
464    /// Deletes the user (their tokens, notifications and sessions go with
465    /// the row) and their data grid preferences. The account page's "delete
466    /// account" does this.
467    pub async fn delete_account(&self, db: &crate::db::Db) -> Result {
468        let mut tx = db.begin().await?;
469        // Every app has this table, and not every app has `users`, so it has
470        // no foreign key to cascade from.
471        crate::db::sql("DELETE FROM grid_preferences WHERE user_id = ?")
472            .bind(self.id)
473            .execute(&mut tx)
474            .await?;
475        crate::db::sql("DELETE FROM users WHERE id = ?")
476            .bind(self.id)
477            .execute(&mut tx)
478            .await?;
479        tx.commit().await?;
480        Ok(())
481    }
482}