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