1use std::sync::Arc;
2
3use axum::extract::{Extension, State};
4use axum::response::{IntoResponse, Redirect, Response};
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7
8use super::events::{LockedOut, LoggedIn, LoggedOut, LoginFailed, Registered, announce};
9use super::user::dummy_hash;
10use super::{User, intended, login, logout, passwords, verification, verify_password};
11use crate::db::Migration;
12use crate::i18n::Lang;
13use crate::validation::{Errors, Valid, Validate, ValidationError, Validator};
14use crate::{
15 AppState, AuthUser, ClientIp, Htmx, HxRedirect, Module, Result, Routes, Session, View, context,
16 view,
17};
18
19const MIGRATIONS: &[Migration] = &[
20 crate::db::framework_migration!("auth", "00010101000000_create_users_table"),
21 crate::db::framework_migration!("auth", "00010101000001_create_password_reset_tokens_table"),
22 crate::db::framework_migration!("auth", "00010101000002_create_personal_access_tokens_table"),
23 crate::db::framework_migration!("auth", "00010101000003_create_notifications_table"),
24 crate::db::framework_migration!("auth", "00010101000004_add_sessions_revoked_at_to_users"),
25 crate::db::framework_migration!(
26 "auth",
27 "00010101000005_add_abilities_to_personal_access_tokens"
28 ),
29 crate::db::framework_migration!("auth", "00010101000006_create_revoked_sessions_table"),
30 crate::db::framework_migration!("auth", "00010101000007_create_device_tokens_table"),
31];
32
33pub(crate) struct Settings {
34 pub(super) password: crate::validation::Password,
35 pub(super) registration: bool,
36 redirect_to: Option<String>,
37 pub(super) verify_email: bool,
38 rules: Option<RulesFn>,
39 pub(super) on_registered: Option<RegisteredFn>,
40}
41
42type RulesFn = Arc<dyn Fn(&Registration, &mut Validator) + Send + Sync>;
43pub(super) type RegisteredFn = Arc<
44 dyn Fn(
45 AppState,
46 User,
47 Registration,
48 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result> + Send>>
49 + Send
50 + Sync,
51>;
52
53#[derive(Debug, Clone)]
57#[non_exhaustive]
58pub struct Registration {
59 pub(super) fields: serde_json::Map<String, Value>,
60}
61
62impl Registration {
63 pub fn get(&self, field: &str) -> String {
65 match self.fields.get(field) {
66 Some(Value::String(value)) => value.trim().to_owned(),
67 Some(Value::Array(values)) => values
68 .first()
69 .and_then(Value::as_str)
70 .unwrap_or_default()
71 .trim()
72 .to_owned(),
73 _ => String::new(),
74 }
75 }
76
77 pub fn all(&self, field: &str) -> Vec<String> {
79 match self.fields.get(field) {
80 Some(Value::String(value)) => vec![value.clone()],
81 Some(Value::Array(values)) => values
82 .iter()
83 .filter_map(|v| v.as_str().map(str::to_owned))
84 .collect(),
85 _ => Vec::new(),
86 }
87 }
88}
89
90#[derive(Clone)]
109pub struct Auth {
110 password: crate::validation::Password,
111 account: bool,
112 registration: bool,
113 redirect_to: Option<String>,
114 verify_email: bool,
115 notifications: bool,
116 rules: Option<RulesFn>,
117 on_registered: Option<RegisteredFn>,
118}
119
120impl Auth {
121 pub fn new() -> Self {
123 Self {
124 password: crate::validation::Password::default(),
125 account: false,
126 registration: true,
127 redirect_to: None,
128 verify_email: false,
129 notifications: false,
130 rules: None,
131 on_registered: None,
132 }
133 }
134
135 pub fn notifications(mut self) -> Self {
148 self.notifications = true;
149 self
150 }
151
152 pub fn registration_rules(
171 mut self,
172 rules: impl Fn(&Registration, &mut Validator) + Send + Sync + 'static,
173 ) -> Self {
174 self.rules = Some(Arc::new(rules));
175 self
176 }
177
178 pub fn on_registered<F, Fut>(mut self, hook: F) -> Self
182 where
183 F: Fn(User, Registration, AppState) -> Fut + Send + Sync + 'static,
184 Fut: std::future::Future<Output = Result> + Send + 'static,
185 {
186 self.on_registered = Some(Arc::new(move |state, user, form| {
187 Box::pin(hook(user, form, state))
188 }));
189 self
190 }
191
192 pub fn verify_email(mut self) -> Self {
195 self.verify_email = true;
196 self
197 }
198
199 pub fn password_rules(mut self, policy: crate::validation::Password) -> Self {
210 self.password = policy;
211 self
212 }
213
214 pub fn account(mut self) -> Self {
220 self.account = true;
221 self
222 }
223
224 pub fn without_registration(mut self) -> Self {
226 self.registration = false;
227 self
228 }
229
230 pub fn redirect_to(mut self, path: &str) -> Self {
233 self.redirect_to = Some(path.to_owned());
234 self
235 }
236
237 fn settings(&self) -> Settings {
238 Settings {
239 password: self.password.clone(),
240 registration: self.registration,
241 redirect_to: self.redirect_to.clone(),
242 verify_email: self.verify_email,
243 rules: self.rules.clone(),
244 on_registered: self.on_registered.clone(),
245 }
246 }
247}
248
249impl Default for Auth {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255impl Module for Auth {
256 fn name(&self) -> &'static str {
257 "auth"
258 }
259
260 fn migrations(&self) -> &'static [Migration] {
261 MIGRATIONS
262 }
263
264 fn register(&self, app: &mut crate::Registry) {
265 app.auth = Some(Arc::new(self.settings()));
267 if self.notifications {
268 app.share(
269 "unread_notifications",
270 |ctx: crate::view::ViewContext| async move {
271 match ctx.user {
272 Some(user) => user.unread_notification_count(&ctx.state.db).await,
273 None => Ok(0),
274 }
275 },
276 );
277 }
278 app.command(
280 "tokens:prune",
281 "Delete API tokens that expired more than a day ago",
282 |_args, state| async move {
283 let day = std::time::Duration::from_secs(24 * 60 * 60);
284 let pruned = super::prune_expired_tokens(&state.db, day).await?
285 + super::DeviceToken::prune_expired(&state.db, day).await?;
286 println!("Deleted {pruned} expired API tokens.");
287 Ok(())
288 },
289 );
290 app.command(
291 "notifications:prune",
292 "Delete notifications read more than --days ago (default 30)",
293 |args, state| async move {
294 let days: u64 = args
295 .value("--days")
296 .unwrap_or("30")
297 .parse()
298 .map_err(|_| crate::Error::BadRequest("--days must be a number".into()))?;
299 let age = std::time::Duration::from_secs(days * 24 * 60 * 60);
300 let pruned = super::prune_read_notifications(&state.db, age).await?;
301 println!("Deleted {pruned} notifications read more than {days} days ago.");
302 Ok(())
303 },
304 );
305 }
306
307 fn routes(&self) -> Routes {
308 let settings = Arc::new(self.settings());
309
310 let mut guest = Routes::new()
311 .get("/login", show_login)
312 .post("/login", store_login)
313 .name("login");
314 if self.registration {
315 guest = guest
316 .get("/register", show_register)
317 .post("/register", store_register)
318 .name("register");
319 }
320 let guest = guest
321 .get("/forgot-password", passwords::show_forgot)
322 .name("password.request")
323 .post("/forgot-password", passwords::send_link)
324 .name("password.email")
325 .get("/reset-password/{token}", passwords::show_reset)
326 .name("password.reset")
327 .post("/reset-password", passwords::reset)
328 .name("password.update")
329 .guest_only();
330 let verification = Routes::new()
331 .get("/verify-email", verification::notice)
332 .name("verification.notice")
333 .get("/verify-email/{id}/{hash}", verification::verify)
334 .name("verification.verify")
335 .post("/email/verification-notification", verification::resend)
336 .name("verification.send")
337 .require_auth();
338 let confirm = Routes::new()
339 .get("/confirm-password", super::account::show_confirm)
340 .post("/confirm-password", super::account::confirm)
341 .name("password.confirm")
342 .require_auth();
343 let mut routes = guest
344 .merge(verification)
345 .merge(confirm)
346 .merge(Routes::new().post("/logout", destroy).name("logout"));
347 if self.account {
348 routes = routes.merge(super::account::routes());
349 }
350 if self.notifications {
351 routes = routes.merge(super::inbox::routes());
352 }
353 routes.route_layer(Extension(settings))
354 }
355}
356
357pub(super) fn texts(lang: &Lang) -> Value {
360 let mut base = text();
361 if let Value::Object(map) = &mut base {
362 for (key, value) in lang.texts().iter() {
363 if let Some(key) = key.strip_prefix("renox.auth.") {
364 map.insert(key.to_owned(), Value::String(value.clone()));
365 }
366 }
367 }
368 base
369}
370
371fn message(lang: &Lang, key: &str, params: &[(&str, String)]) -> String {
373 let template = crate::validation::template_for(Some(&lang.texts()), key);
374 crate::i18n::format(&template, params, None)
375}
376
377pub(super) fn after_login(state: &AppState, settings: &Settings, session: &Session) -> String {
378 let fallback = settings
379 .redirect_to
380 .clone()
381 .unwrap_or_else(|| state.url("home", &[]).unwrap_or_else(|_| "/".into()));
382 intended(session, fallback)
383}
384
385pub(super) fn go(htmx: &Htmx, to: String) -> Response {
387 if htmx.request {
388 HxRedirect(to).into_response()
389 } else {
390 Redirect::to(&to).into_response()
391 }
392}
393
394async fn show_login(Extension(settings): Extension<Arc<Settings>>, lang: Lang) -> View {
395 view(
396 "renox/auth/login.html",
397 context! { registration => settings.registration, text => texts(&lang) },
398 )
399}
400
401#[derive(Deserialize, Serialize)]
402struct LoginForm {
403 email: String,
404 password: String,
405 remember: Option<String>,
406}
407
408pub(super) fn label(_v: &Validator, field: &'static str) -> &'static str {
410 match field {
411 "current_password" => "current password",
412 _ => field,
413 }
414}
415
416impl Validate for LoginForm {
417 fn rules(&self, v: &mut Validator) {
418 v.field("email", &super::user::normalize_email(&self.email))
419 .required()
420 .email();
421 let password = label(v, "password");
422 v.field("password", &self.password)
423 .fallback_label(password)
424 .required();
425 }
426}
427
428async fn store_login(
429 Extension(settings): Extension<Arc<Settings>>,
430 State(state): State<AppState>,
431 session: Session,
432 htmx: Htmx,
433 ClientIp(ip): ClientIp,
434 lang: Lang,
435 Valid(form): Valid<LoginForm>,
436) -> Result<Response> {
437 let failed = |key: &str, seconds: Option<u64>| {
438 let mut errors = Errors::new();
439 let seconds = seconds.map(|s| s.to_string()).unwrap_or_default();
440 errors.add("email", message(&lang, key, &[("seconds", seconds)]));
441 ValidationError::new(errors)
442 .with_input(&json!({ "email": form.email, "remember": form.remember }))
443 };
444
445 let address = ip.map(|ip| ip.to_string());
446 if let Some(seconds) = state.throttle.blocked_for(&form.email, ip).await {
447 let event = LockedOut {
448 email: form.email.clone(),
449 ip: address,
450 seconds,
451 };
452 announce(&state, event).await;
453 return Err(failed("auth.throttle", Some(seconds)).into());
454 }
455
456 let user = User::find_by_email(&state.db, &form.email).await?;
457 let hash = user
459 .as_ref()
460 .map_or_else(dummy_hash, |u| u.password.clone());
461 let valid = verify_password(&form.password, &hash).await;
462 let Some(mut user) = user.filter(|_| valid) else {
463 state.throttle.fail(&form.email, ip).await;
464 let event = LoginFailed {
465 email: form.email.clone(),
466 ip: address,
467 };
468 announce(&state, event).await;
469 return Err(failed("auth.failed", None).into());
470 };
471 user.rehash_if_needed(&state.db, &form.password).await?;
472
473 if let Some(second) = &state.second_factor
477 && (second.required)(user.clone(), state.clone()).await?
478 {
479 let to = after_login(&state, &settings, &session);
480 super::second_factor::begin(&session, &user, &form.email, form.remember.is_some(), to)?;
481 let challenge = state.url(&second.challenge, &[])?;
482 return Ok(go(&htmx, challenge));
483 }
484
485 state.throttle.clear(&form.email, ip).await;
486 let remember = form
487 .remember
488 .is_some()
489 .then_some(state.config.remember_lifetime);
490 login(&session, &user, remember)?;
491 super::account::mark_confirmed(&session)?;
492 let event = LoggedIn {
493 user_id: user.id,
494 ip: address,
495 };
496 announce(&state, event).await;
497 Ok(go(&htmx, after_login(&state, &settings, &session)))
498}
499
500async fn show_register(lang: Lang) -> View {
501 view(
502 "renox/auth/register.html",
503 context! { text => texts(&lang) },
504 )
505}
506
507#[derive(Deserialize)]
508struct RegisterForm {
509 name: String,
510 email: String,
511 password: String,
512 password_confirmation: Option<String>,
513}
514
515impl Validate for RegisterForm {
516 fn rules(&self, v: &mut Validator) {
517 let (name, password) = (label(v, "name"), label(v, "password"));
518 v.field("name", &self.name)
519 .fallback_label(name)
520 .required()
521 .max(255);
522 v.field("email", &super::user::normalize_email(&self.email))
524 .required()
525 .email()
526 .max(255)
527 .unique("users", "email");
528 let _ = password;
530 }
531}
532
533async fn store_register(
534 Extension(settings): Extension<Arc<Settings>>,
535 State(state): State<AppState>,
536 session: Session,
537 htmx: Htmx,
538 lang: Lang,
539 req: axum::extract::Request,
540) -> Result<Response> {
541 let rules = settings.rules.clone();
542 let policy = settings.password.clone();
543 let validated = crate::validation::extract::validate_request(
544 req,
545 &state,
546 move |form: &RegisterForm, fields, v| {
547 let password = label(v, "password");
548 v.field("password", &form.password)
549 .fallback_label(password)
550 .required()
551 .password(&policy)
552 .confirmed(&form.password_confirmation);
553 if let Some(rules) = &rules {
554 rules(
555 &Registration {
556 fields: fields.clone(),
557 },
558 v,
559 );
560 }
561 },
562 )
563 .await;
564 let (form, fields) = match validated {
565 Ok(validated) => validated,
566 Err(rejection) => return Ok(rejection),
567 };
568 let user = match User::register(&state.db, &form.name, &form.email, &form.password).await {
569 Ok(user) => user,
570 Err(err) if err.is_unique_violation() => {
573 let template = crate::validation::template_for(Some(&lang.texts()), "unique");
574 let mut errors = Errors::new();
575 errors.add("email", crate::validation::render(&template, "email", &[]));
576 return Err(ValidationError::new(errors)
577 .with_input(&json!({ "name": form.name, "email": form.email }))
578 .into());
579 }
580 Err(err) => return Err(err),
581 };
582 let user = match &settings.on_registered {
583 None => user,
584 Some(hook) => {
585 let id = user.id;
586 if let Err(err) = hook(state.clone(), user, Registration { fields }).await {
587 crate::db::sql("DELETE FROM users WHERE id = ?")
589 .bind(id)
590 .execute(&state.db)
591 .await?;
592 return Err(err);
593 }
594 <User as crate::db::Model>::find_or_404(&state.db, id).await?
596 }
597 };
598 if settings.verify_email {
599 verification::send_verification(&state, &user).await?;
600 }
601 let event = Registered {
602 user_id: user.id,
603 email: user.email.clone(),
604 };
605 if let Some(second) = &state.second_factor
608 && (second.required)(user.clone(), state.clone()).await?
609 {
610 let to = after_login(&state, &settings, &session);
611 super::second_factor::begin(&session, &user, &user.email, false, to)?;
612 announce(&state, event).await;
613 let challenge = state.url(&second.challenge, &[])?;
614 return Ok(go(&htmx, challenge));
615 }
616 login(&session, &user, None)?;
617 announce(&state, event).await;
618 Ok(go(&htmx, after_login(&state, &settings, &session)))
619}
620
621async fn destroy(
622 State(state): State<AppState>,
623 session: Session,
624 user: Option<AuthUser>,
625 htmx: Htmx,
626) -> Result<Response> {
627 logout(&state.db, &session).await?;
628 if let Some(user) = user {
629 announce(&state, LoggedOut { user_id: user.id }).await;
630 }
631 Ok(go(
632 &htmx,
633 state.url("home", &[]).unwrap_or_else(|_| "/".into()),
634 ))
635}
636
637pub(super) fn text() -> Value {
640 json!({
641 "login_title": "Log in",
642 "register_title": "Create an account",
643 "name": "Name",
644 "email": "Email",
645 "password": "Password",
646 "password_confirmation": "Confirm password",
647 "remember": "Remember me",
648 "login_button": "Log in",
649 "register_button": "Register",
650 "no_account": "No account yet?",
651 "have_account": "Already registered?",
652 "forgot_link": "Forgot your password?",
653 "forgot_title": "Forgot your password?",
654 "forgot_intro": "Enter your email and we'll send you a link to choose a new password.",
655 "send_link": "Email me a reset link",
656 "back_to_login": "Back to log in",
657 "reset_title": "Choose a new password",
658 "reset_button": "Reset password",
659 "reset_link_sent": "If that email has an account, a reset link is on its way.",
660 "reset_invalid": "This password reset link is invalid or has expired.",
661 "password_reset_done": "Your password has been reset. You can log in now.",
662 "verify_title": "Verify your email",
663 "verify_intro": "We've emailed you a link to verify your address. Didn't get it?",
664 "resend_button": "Send another link",
665 "logout": "Log out",
666 "verification_sent": "A new verification link has been sent.",
667 "verified": "Your email address is verified.",
668 "mail_reset_subject": "Reset your password",
669 "mail_reset_intro": "You asked to reset your password. Choose a new one with the button below.",
670 "mail_reset_outro": "The link works for 60 minutes. If you didn't ask for this, ignore this email.",
671 "mail_verify_subject": "Verify your email address",
672 "mail_verify_intro": "Please confirm that this is your email address.",
673 "mail_verify_outro": "The link works for 60 minutes.",
674 "account_title": "Your account",
675 "profile_title": "Profile",
676 "profile_saved": "Your profile is saved.",
677 "email_unverified": "Your new email address isn't verified yet: check your inbox.",
678 "save": "Save",
679 "password_title": "Change password",
680 "set_password_title": "Set a password",
681 "set_password_intro": "You log in with a linked account. Choose a password to log in with it too.",
682 "current_password": "Current password",
683 "new_password": "New password",
684 "password_changed": "Your password is changed. Your other devices are logged out.",
685 "other_devices_title": "Other devices",
686 "other_devices_intro": "Log out everywhere else, e.g. on a phone you lost.",
687 "other_devices_button": "Log out other devices",
688 "other_devices_logged_out": "Your other devices are logged out.",
689 "delete_title": "Delete account",
690 "delete_intro": "Your account and its data are deleted for good.",
691 "delete_button": "Delete my account",
692 "delete_confirm": "Delete your account for good?",
693 "confirm_title": "Confirm your password",
694 "confirm_intro": "This is a secure area. Please confirm your password to continue.",
695 "confirm_button": "Confirm",
696 })
697}