1use std::sync::Arc;
2
3use axum::Extension;
4use axum::Router;
5use axum::extract::State;
6use axum::http::HeaderMap;
7use axum::http::Uri;
8use axum::http::header::USER_AGENT;
9use axum::response::{Html, IntoResponse, Response};
10use axum::routing::{get, post};
11use minijinja::{Environment, context};
12use serde::Deserialize;
13
14use allowthem_core::types::User;
15use allowthem_core::{AllowThem, AuditEvent, AuthError, Email, OAuthAccountInfo, Username};
16
17use crate::browser_error::BrowserError;
18use crate::csrf::CsrfToken;
19use crate::error::BrowserAuthRedirect;
20
21const MIN_PASSWORD_LEN: usize = 8;
22
23#[derive(Clone)]
24struct SettingsConfig {
25 templates: Arc<Environment<'static>>,
26 is_production: bool,
27}
28
29#[derive(Deserialize)]
30pub struct ProfileForm {
31 email: String,
32 #[serde(default)]
33 username: String,
34 #[allow(dead_code)]
35 csrf_token: String,
36}
37
38#[derive(Deserialize)]
39pub struct PasswordForm {
40 current_password: String,
41 new_password: String,
42 new_password_confirm: String,
43 #[allow(dead_code)]
44 csrf_token: String,
45}
46
47struct SettingsContext {
48 email: String,
49 username: String,
50 profile_error: String,
51 profile_success: String,
52 password_error: String,
53 password_success: String,
54 oauth_accounts: Vec<OAuthAccountInfo>,
55 mfa_enabled: bool,
56 mfa_recovery_remaining: i64,
57}
58
59fn render_settings(
60 config: &SettingsConfig,
61 csrf_token: &str,
62 ctx: &SettingsContext,
63) -> Result<Html<String>, BrowserError> {
64 crate::browser_templates::render(
65 &config.templates,
66 "settings.html",
67 context! {
68 csrf_token,
69 email => &ctx.email,
70 username => &ctx.username,
71 profile_error => &ctx.profile_error,
72 profile_success => &ctx.profile_success,
73 password_error => &ctx.password_error,
74 password_success => &ctx.password_success,
75 oauth_accounts => &ctx.oauth_accounts,
76 mfa_enabled => ctx.mfa_enabled,
77 mfa_recovery_remaining => ctx.mfa_recovery_remaining,
78 is_production => config.is_production,
79 },
80 )
81}
82
83async fn fetch_account_data(
84 ath: &AllowThem,
85 user_id: allowthem_core::types::UserId,
86) -> Result<(Vec<OAuthAccountInfo>, bool, i64), BrowserError> {
87 let oauth_accounts = ath.db().get_user_oauth_accounts(user_id).await?;
88 let mfa_enabled = ath.db().has_mfa_enabled(user_id).await?;
89 let mfa_recovery_remaining = if mfa_enabled {
90 ath.db().remaining_recovery_codes(user_id).await?
91 } else {
92 0
93 };
94 Ok((oauth_accounts, mfa_enabled, mfa_recovery_remaining))
95}
96
97fn client_ip(headers: &HeaderMap) -> Option<String> {
98 headers
99 .get("x-forwarded-for")
100 .and_then(|v| v.to_str().ok())
101 .and_then(|s| s.split(',').next())
102 .map(|s| s.trim().to_string())
103}
104
105async fn require_browser_user(
111 ath: &AllowThem,
112 headers: &HeaderMap,
113 path: &str,
114) -> Result<User, Response> {
115 let cookie_header = headers
116 .get(axum::http::header::COOKIE)
117 .and_then(|v| v.to_str().ok())
118 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
119
120 let token = ath
121 .parse_session_cookie(cookie_header)
122 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
123
124 let ttl = ath.session_config().ttl;
125 let session = ath
126 .db()
127 .validate_session(&token, ttl)
128 .await
129 .map_err(|err| {
130 tracing::error!("session validation error: {err}");
131 BrowserAuthRedirect::new(path).into_response()
132 })?
133 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
134
135 match ath.db().get_user(session.user_id).await {
136 Ok(user) if user.is_active => Ok(user),
137 Ok(_) => Err(BrowserAuthRedirect::new(path).into_response()),
138 Err(AuthError::NotFound) => Err(BrowserAuthRedirect::new(path).into_response()),
139 Err(err) => {
140 tracing::error!("user lookup error: {err}");
141 Err(BrowserAuthRedirect::new(path).into_response())
142 }
143 }
144}
145
146async fn get_settings(
148 State(ath): State<AllowThem>,
149 Extension(config): Extension<SettingsConfig>,
150 uri: Uri,
151 csrf: CsrfToken,
152 headers: HeaderMap,
153) -> Result<Response, BrowserError> {
154 let user = match require_browser_user(&ath, &headers, uri.path()).await {
155 Ok(u) => u,
156 Err(redirect) => return Ok(redirect),
157 };
158
159 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
160 fetch_account_data(&ath, user.id).await?;
161
162 let ctx = SettingsContext {
163 email: user.email.as_str().to_string(),
164 username: user
165 .username
166 .as_ref()
167 .map_or(String::new(), |u| u.as_str().to_string()),
168 profile_error: String::new(),
169 profile_success: String::new(),
170 password_error: String::new(),
171 password_success: String::new(),
172 oauth_accounts,
173 mfa_enabled,
174 mfa_recovery_remaining,
175 };
176 let html = render_settings(&config, csrf.as_str(), &ctx)?;
177 Ok(html.into_response())
178}
179
180async fn post_settings(
182 State(ath): State<AllowThem>,
183 Extension(config): Extension<SettingsConfig>,
184 uri: Uri,
185 csrf: CsrfToken,
186 headers: HeaderMap,
187 axum::Form(form): axum::Form<ProfileForm>,
188) -> Result<Response, BrowserError> {
189 let user = match require_browser_user(&ath, &headers, uri.path()).await {
190 Ok(u) => u,
191 Err(redirect) => return Ok(redirect),
192 };
193
194 let email = match Email::new(form.email.clone()) {
196 Ok(e) => e,
197 Err(_) => {
198 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
199 fetch_account_data(&ath, user.id).await?;
200 let ctx = SettingsContext {
201 email: form.email,
202 username: form.username,
203 profile_error: "Invalid email address".into(),
204 profile_success: String::new(),
205 password_error: String::new(),
206 password_success: String::new(),
207 oauth_accounts,
208 mfa_enabled,
209 mfa_recovery_remaining,
210 };
211 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
212 }
213 };
214
215 let trimmed = form.username.trim();
217 let username = if trimmed.is_empty() {
218 None
219 } else {
220 Some(Username::new(trimmed))
221 };
222
223 if email != user.email {
225 match ath.db().update_user_email(user.id, email).await {
226 Ok(()) => {}
227 Err(AuthError::Conflict(ref msg)) if msg.contains("email") => {
228 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
229 fetch_account_data(&ath, user.id).await?;
230 let ctx = SettingsContext {
231 email: form.email,
232 username: form.username,
233 profile_error: "An account with this email already exists".into(),
234 profile_success: String::new(),
235 password_error: String::new(),
236 password_success: String::new(),
237 oauth_accounts,
238 mfa_enabled,
239 mfa_recovery_remaining,
240 };
241 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
242 }
243 Err(e) => return Err(BrowserError::Auth(e)),
244 }
245 }
246
247 let current_username = user.username.as_ref().map(|u| u.as_str());
252 let new_username = username.as_ref().map(|u| u.as_str());
253 if current_username != new_username {
254 match ath.db().update_user_username(user.id, username).await {
255 Ok(()) => {}
256 Err(AuthError::Conflict(ref msg)) if msg.contains("username") => {
257 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
258 fetch_account_data(&ath, user.id).await?;
259 let ctx = SettingsContext {
260 email: form.email,
261 username: form.username,
262 profile_error: "This username is already taken".into(),
263 profile_success: String::new(),
264 password_error: String::new(),
265 password_success: String::new(),
266 oauth_accounts,
267 mfa_enabled,
268 mfa_recovery_remaining,
269 };
270 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
271 }
272 Err(e) => return Err(BrowserError::Auth(e)),
273 }
274 }
275
276 let ip = client_ip(&headers);
278 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
279 let _ = ath
280 .db()
281 .log_audit(
282 AuditEvent::UserUpdated,
283 Some(&user.id),
284 None,
285 ip.as_deref(),
286 ua,
287 None,
288 )
289 .await;
290
291 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
293 fetch_account_data(&ath, user.id).await?;
294 let ctx = SettingsContext {
295 email: form.email,
296 username: form.username,
297 profile_error: String::new(),
298 profile_success: "Profile updated".into(),
299 password_error: String::new(),
300 password_success: String::new(),
301 oauth_accounts,
302 mfa_enabled,
303 mfa_recovery_remaining,
304 };
305 Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response())
306}
307
308async fn post_change_password(
310 State(ath): State<AllowThem>,
311 Extension(config): Extension<SettingsConfig>,
312 uri: Uri,
313 csrf: CsrfToken,
314 headers: HeaderMap,
315 axum::Form(form): axum::Form<PasswordForm>,
316) -> Result<Response, BrowserError> {
317 let user = match require_browser_user(&ath, &headers, uri.path()).await {
318 Ok(u) => u,
319 Err(redirect) => return Ok(redirect),
320 };
321
322 let ip = client_ip(&headers);
323 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
324
325 if form.new_password.len() < MIN_PASSWORD_LEN {
327 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
328 fetch_account_data(&ath, user.id).await?;
329 let ctx = SettingsContext {
330 email: user.email.as_str().to_string(),
331 username: user
332 .username
333 .as_ref()
334 .map_or(String::new(), |u| u.as_str().to_string()),
335 profile_error: String::new(),
336 profile_success: String::new(),
337 password_error: "New password must be at least 8 characters".into(),
338 password_success: String::new(),
339 oauth_accounts,
340 mfa_enabled,
341 mfa_recovery_remaining,
342 };
343 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
344 }
345
346 if form.new_password != form.new_password_confirm {
348 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
349 fetch_account_data(&ath, user.id).await?;
350 let ctx = SettingsContext {
351 email: user.email.as_str().to_string(),
352 username: user
353 .username
354 .as_ref()
355 .map_or(String::new(), |u| u.as_str().to_string()),
356 profile_error: String::new(),
357 profile_success: String::new(),
358 password_error: "New passwords do not match".into(),
359 password_success: String::new(),
360 oauth_accounts,
361 mfa_enabled,
362 mfa_recovery_remaining,
363 };
364 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
365 }
366
367 let fetched_user = ath.db().find_for_login(user.email.as_str()).await?;
369
370 let password_ok = match fetched_user.password_hash {
371 Some(ref h) => {
372 allowthem_core::password::verify_password(&form.current_password, h).unwrap_or(false)
373 }
374 None => false,
375 };
376
377 if !password_ok {
378 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
379 fetch_account_data(&ath, user.id).await?;
380 let ctx = SettingsContext {
381 email: user.email.as_str().to_string(),
382 username: user
383 .username
384 .as_ref()
385 .map_or(String::new(), |u| u.as_str().to_string()),
386 profile_error: String::new(),
387 profile_success: String::new(),
388 password_error: "Current password is incorrect".into(),
389 password_success: String::new(),
390 oauth_accounts,
391 mfa_enabled,
392 mfa_recovery_remaining,
393 };
394 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
395 }
396
397 ath.db()
399 .update_user_password(user.id, &form.new_password)
400 .await?;
401
402 ath.db().delete_user_sessions(&user.id).await?;
404
405 let token = allowthem_core::generate_token();
406 let token_hash = allowthem_core::hash_token(&token);
407 let expires_at = chrono::Utc::now() + ath.session_config().ttl;
408 ath.db()
409 .create_session(user.id, token_hash, ip.as_deref(), ua, expires_at)
410 .await?;
411 let cookie = ath.session_cookie(&token);
412
413 let _ = ath
415 .db()
416 .log_audit(
417 AuditEvent::PasswordChange,
418 Some(&user.id),
419 None,
420 ip.as_deref(),
421 ua,
422 None,
423 )
424 .await;
425
426 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
428 fetch_account_data(&ath, user.id).await?;
429 let ctx = SettingsContext {
430 email: user.email.as_str().to_string(),
431 username: user
432 .username
433 .as_ref()
434 .map_or(String::new(), |u| u.as_str().to_string()),
435 profile_error: String::new(),
436 profile_success: String::new(),
437 password_error: String::new(),
438 password_success: "Password changed successfully".into(),
439 oauth_accounts,
440 mfa_enabled,
441 mfa_recovery_remaining,
442 };
443 let html = render_settings(&config, csrf.as_str(), &ctx)?;
444
445 Ok(([(axum::http::header::SET_COOKIE, cookie)], html).into_response())
446}
447
448pub fn settings_routes(
449 templates: Arc<Environment<'static>>,
450 is_production: bool,
451) -> Router<AllowThem> {
452 let cfg = SettingsConfig {
453 templates,
454 is_production,
455 };
456 Router::new()
457 .route("/settings", get(get_settings).post(post_settings))
458 .route("/settings/password", post(post_change_password))
459 .layer(Extension(cfg))
460}
461
462#[cfg(test)]
463mod tests {
464 use std::sync::Arc;
465
466 use axum::Router;
467 use axum::body::Body;
468 use axum::http::{Request, StatusCode, header};
469 use tower::ServiceExt;
470
471 use allowthem_core::{
472 AllowThem, AllowThemBuilder, AuditEvent, Email, Username, generate_token, hash_token,
473 parse_session_cookie,
474 };
475
476 use super::{SettingsConfig, settings_routes};
477
478 async fn setup() -> (AllowThem, SettingsConfig, String) {
479 let ath = AllowThemBuilder::new("sqlite::memory:")
480 .cookie_secure(false)
481 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
482 .build()
483 .await
484 .unwrap();
485
486 let templates = crate::browser_templates::build_default_browser_env();
487
488 let email = Email::new("user@example.com".into()).unwrap();
489 let user = ath
490 .db()
491 .create_user(email, "password123", Some(Username::new("testuser")), None)
492 .await
493 .unwrap();
494
495 let token = generate_token();
496 let token_hash = hash_token(&token);
497 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
498 ath.db()
499 .create_session(user.id, token_hash, None, None, expires)
500 .await
501 .unwrap();
502 let set_cookie = ath.session_cookie(&token);
503 let cookie_value = set_cookie.split(';').next().unwrap().to_string();
504
505 let config = SettingsConfig {
506 templates,
507 is_production: false,
508 };
509
510 (ath, config, cookie_value)
511 }
512
513 fn test_app(ath: AllowThem, config: SettingsConfig) -> Router {
514 settings_routes(config.templates.clone(), config.is_production)
515 .layer(axum::middleware::from_fn_with_state(
516 ath.clone(),
517 crate::csrf::csrf_middleware,
518 ))
519 .with_state(ath)
520 }
521
522 async fn get_csrf_token(app: &Router, cookie: &str) -> String {
523 let req = Request::builder()
524 .uri("/settings")
525 .header(header::COOKIE, cookie)
526 .body(Body::empty())
527 .unwrap();
528 let resp = app.clone().oneshot(req).await.unwrap();
529 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
530 .await
531 .unwrap();
532 let html = String::from_utf8(bytes.to_vec()).unwrap();
533 let marker = "name=\"csrf_token\" value=\"";
534 let start = html.find(marker).expect("csrf_token not found in HTML") + marker.len();
535 let end = html[start..].find('"').unwrap() + start;
536 html[start..end].to_string()
537 }
538
539 async fn body_string(resp: axum::http::Response<Body>) -> String {
540 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
541 .await
542 .unwrap();
543 String::from_utf8(bytes.to_vec()).unwrap()
544 }
545
546 fn profile_request(
547 csrf: &str,
548 session_cookie: &str,
549 email: &str,
550 username: &str,
551 ) -> Request<Body> {
552 let enc = |s: &str| s.replace('@', "%40");
553 let body = format!(
554 "csrf_token={}&email={}&username={}",
555 csrf,
556 enc(email),
557 enc(username),
558 );
559 Request::builder()
560 .method("POST")
561 .uri("/settings")
562 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
563 .header(
564 header::COOKIE,
565 format!("{session_cookie}; csrf_token={csrf}"),
566 )
567 .body(Body::from(body))
568 .unwrap()
569 }
570
571 fn password_request(
572 csrf: &str,
573 session_cookie: &str,
574 current: &str,
575 new: &str,
576 confirm: &str,
577 ) -> Request<Body> {
578 let body = format!(
579 "csrf_token={csrf}¤t_password={current}&new_password={new}&new_password_confirm={confirm}",
580 );
581 Request::builder()
582 .method("POST")
583 .uri("/settings/password")
584 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
585 .header(
586 header::COOKIE,
587 format!("{session_cookie}; csrf_token={csrf}"),
588 )
589 .body(Body::from(body))
590 .unwrap()
591 }
592
593 #[tokio::test]
596 async fn get_settings_renders_page() {
597 let (ath, config, cookie) = setup().await;
598 let app = test_app(ath, config);
599 let req = Request::builder()
600 .uri("/settings")
601 .header(header::COOKIE, &cookie)
602 .body(Body::empty())
603 .unwrap();
604 let resp = app.oneshot(req).await.unwrap();
605 assert_eq!(resp.status(), StatusCode::OK);
606 let html = body_string(resp).await;
607 assert!(html.contains("user@example.com"));
608 assert!(html.contains("testuser"));
609 assert!(html.contains("Settings"));
610 }
611
612 #[tokio::test]
613 async fn get_settings_unauthenticated_redirects() {
614 let (ath, config, _) = setup().await;
615 let app = test_app(ath, config);
616 let req = Request::builder()
617 .uri("/settings")
618 .body(Body::empty())
619 .unwrap();
620 let resp = app.oneshot(req).await.unwrap();
621 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
622 assert_eq!(
623 resp.headers().get("location").unwrap(),
624 "/login?next=/settings"
625 );
626 }
627
628 #[tokio::test]
629 async fn get_settings_shows_csrf_token() {
630 let (ath, config, cookie) = setup().await;
631 let app = test_app(ath, config);
632 let req = Request::builder()
633 .uri("/settings")
634 .header(header::COOKIE, &cookie)
635 .body(Body::empty())
636 .unwrap();
637 let resp = app.oneshot(req).await.unwrap();
638 let html = body_string(resp).await;
639 assert!(html.contains("name=\"csrf_token\""));
640 }
641
642 #[tokio::test]
643 async fn get_settings_shows_oauth_section() {
644 let (ath, config, cookie) = setup().await;
645 let app = test_app(ath, config);
646 let req = Request::builder()
647 .uri("/settings")
648 .header(header::COOKIE, &cookie)
649 .body(Body::empty())
650 .unwrap();
651 let resp = app.oneshot(req).await.unwrap();
652 let html = body_string(resp).await;
653 assert!(html.contains("Linked accounts"));
654 assert!(html.contains("No linked accounts"));
655 }
656
657 #[tokio::test]
658 async fn get_settings_shows_mfa_section() {
659 let (ath, config, cookie) = setup().await;
660 let app = test_app(ath, config);
661 let req = Request::builder()
662 .uri("/settings")
663 .header(header::COOKIE, &cookie)
664 .body(Body::empty())
665 .unwrap();
666 let resp = app.oneshot(req).await.unwrap();
667 let html = body_string(resp).await;
668 assert!(html.contains("Two-factor authentication"));
669 assert!(html.contains("Not configured"));
670 }
671
672 #[tokio::test]
675 async fn post_settings_updates_email() {
676 let (ath, config, cookie) = setup().await;
677 let app = test_app(ath.clone(), config);
678 let csrf = get_csrf_token(&app, &cookie).await;
679 let req = profile_request(&csrf, &cookie, "new@example.com", "testuser");
680 let resp = app.oneshot(req).await.unwrap();
681 assert_eq!(resp.status(), StatusCode::OK);
682 let html = body_string(resp).await;
683 assert!(html.contains("Profile updated"));
684
685 let email = Email::new("new@example.com".into()).unwrap();
686 let user = ath.db().get_user_by_email(&email).await;
687 assert!(user.is_ok());
688 }
689
690 #[tokio::test]
691 async fn post_settings_updates_username() {
692 let (ath, config, cookie) = setup().await;
693 let app = test_app(ath.clone(), config);
694 let csrf = get_csrf_token(&app, &cookie).await;
695 let req = profile_request(&csrf, &cookie, "user@example.com", "newname");
696 let resp = app.oneshot(req).await.unwrap();
697 assert_eq!(resp.status(), StatusCode::OK);
698
699 let username = Username::new("newname");
700 let user = ath.db().get_user_by_username(&username).await;
701 assert!(user.is_ok());
702 }
703
704 #[tokio::test]
705 async fn post_settings_clears_username() {
706 let (ath, config, cookie) = setup().await;
707 let app = test_app(ath.clone(), config);
708 let csrf = get_csrf_token(&app, &cookie).await;
709 let req = profile_request(&csrf, &cookie, "user@example.com", "");
710 let resp = app.oneshot(req).await.unwrap();
711 assert_eq!(resp.status(), StatusCode::OK);
712
713 let email = Email::new("user@example.com".into()).unwrap();
714 let user = ath.db().get_user_by_email(&email).await.unwrap();
715 assert!(user.username.is_none());
716 }
717
718 #[tokio::test]
719 async fn post_settings_duplicate_email_shows_error() {
720 let (ath, config, cookie) = setup().await;
721 let other_email = Email::new("other@example.com".into()).unwrap();
722 ath.db()
723 .create_user(other_email, "password123", None, None)
724 .await
725 .unwrap();
726
727 let app = test_app(ath, config);
728 let csrf = get_csrf_token(&app, &cookie).await;
729 let req = profile_request(&csrf, &cookie, "other@example.com", "testuser");
730 let resp = app.oneshot(req).await.unwrap();
731 let html = body_string(resp).await;
732 assert!(html.contains("An account with this email already exists"));
733 }
734
735 #[tokio::test]
736 async fn post_settings_duplicate_username_shows_error() {
737 let (ath, config, cookie) = setup().await;
738 let other_email = Email::new("other@example.com".into()).unwrap();
739 ath.db()
740 .create_user(
741 other_email,
742 "password123",
743 Some(Username::new("taken")),
744 None,
745 )
746 .await
747 .unwrap();
748
749 let app = test_app(ath, config);
750 let csrf = get_csrf_token(&app, &cookie).await;
751 let req = profile_request(&csrf, &cookie, "user@example.com", "taken");
752 let resp = app.oneshot(req).await.unwrap();
753 let html = body_string(resp).await;
754 assert!(html.contains("This username is already taken"));
755 }
756
757 #[tokio::test]
758 async fn post_settings_invalid_email_shows_error() {
759 let (ath, config, cookie) = setup().await;
760 let app = test_app(ath, config);
761 let csrf = get_csrf_token(&app, &cookie).await;
762 let req = profile_request(&csrf, &cookie, "not-an-email", "testuser");
763 let resp = app.oneshot(req).await.unwrap();
764 let html = body_string(resp).await;
765 assert!(html.contains("Invalid email address"));
766 }
767
768 #[tokio::test]
769 async fn post_settings_no_changes_succeeds() {
770 let (ath, config, cookie) = setup().await;
771 let app = test_app(ath, config);
772 let csrf = get_csrf_token(&app, &cookie).await;
773 let req = profile_request(&csrf, &cookie, "user@example.com", "testuser");
774 let resp = app.oneshot(req).await.unwrap();
775 let html = body_string(resp).await;
776 assert!(html.contains("Profile updated"));
777 }
778
779 #[tokio::test]
780 async fn post_settings_logs_audit() {
781 let (ath, config, cookie) = setup().await;
782 let app = test_app(ath.clone(), config);
783 let csrf = get_csrf_token(&app, &cookie).await;
784 let req = profile_request(&csrf, &cookie, "user@example.com", "testuser");
785 app.oneshot(req).await.unwrap();
786
787 let entries = ath.db().get_audit_log(None, 10, 0).await.unwrap();
788 let updated = entries
789 .iter()
790 .find(|e| e.event_type == AuditEvent::UserUpdated);
791 assert!(
792 updated.is_some(),
793 "UserUpdated audit event should be recorded"
794 );
795 }
796
797 #[tokio::test]
798 async fn post_settings_requires_csrf() {
799 let (ath, config, cookie) = setup().await;
800 let app = test_app(ath, config);
801 let body = "email=user%40example.com&username=testuser";
802 let req = Request::builder()
803 .method("POST")
804 .uri("/settings")
805 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
806 .header(header::COOKIE, &cookie)
807 .body(Body::from(body))
808 .unwrap();
809 let resp = app.oneshot(req).await.unwrap();
810 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
811 }
812
813 #[tokio::test]
816 async fn post_password_change_success() {
817 let (ath, config, cookie) = setup().await;
818 let app = test_app(ath.clone(), config);
819 let csrf = get_csrf_token(&app, &cookie).await;
820 let req = password_request(
821 &csrf,
822 &cookie,
823 "password123",
824 "newpassword456",
825 "newpassword456",
826 );
827 let resp = app.oneshot(req).await.unwrap();
828 assert_eq!(resp.status(), StatusCode::OK);
829 let html = body_string(resp).await;
830 assert!(html.contains("Password changed successfully"));
831
832 let email = Email::new("user@example.com".into()).unwrap();
834 let user = ath.db().get_user_by_email(&email).await.unwrap();
835 let user_with_hash = ath.db().find_for_login(user.email.as_str()).await.unwrap();
836 let ok = allowthem_core::password::verify_password(
837 "newpassword456",
838 user_with_hash.password_hash.as_ref().unwrap(),
839 )
840 .unwrap();
841 assert!(ok, "new password should verify");
842 }
843
844 #[tokio::test]
845 async fn post_password_wrong_current() {
846 let (ath, config, cookie) = setup().await;
847 let app = test_app(ath, config);
848 let csrf = get_csrf_token(&app, &cookie).await;
849 let req = password_request(
850 &csrf,
851 &cookie,
852 "wrongpassword",
853 "newpassword456",
854 "newpassword456",
855 );
856 let resp = app.oneshot(req).await.unwrap();
857 let html = body_string(resp).await;
858 assert!(html.contains("Current password is incorrect"));
859 }
860
861 #[tokio::test]
862 async fn post_password_too_short() {
863 let (ath, config, cookie) = setup().await;
864 let app = test_app(ath, config);
865 let csrf = get_csrf_token(&app, &cookie).await;
866 let req = password_request(&csrf, &cookie, "password123", "abc", "abc");
867 let resp = app.oneshot(req).await.unwrap();
868 let html = body_string(resp).await;
869 assert!(html.contains("New password must be at least 8 characters"));
870 }
871
872 #[tokio::test]
873 async fn post_password_mismatch() {
874 let (ath, config, cookie) = setup().await;
875 let app = test_app(ath, config);
876 let csrf = get_csrf_token(&app, &cookie).await;
877 let req = password_request(
878 &csrf,
879 &cookie,
880 "password123",
881 "newpassword1",
882 "newpassword2",
883 );
884 let resp = app.oneshot(req).await.unwrap();
885 let html = body_string(resp).await;
886 assert!(html.contains("New passwords do not match"));
887 }
888
889 #[tokio::test]
890 async fn post_password_invalidates_other_sessions() {
891 let (ath, config, cookie) = setup().await;
892
893 let email = Email::new("user@example.com".into()).unwrap();
895 let user = ath.db().get_user_by_email(&email).await.unwrap();
896 let token2 = generate_token();
897 let token2_hash = hash_token(&token2);
898 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
899 ath.db()
900 .create_session(user.id, token2_hash, None, None, expires)
901 .await
902 .unwrap();
903
904 let app = test_app(ath.clone(), config);
905 let csrf = get_csrf_token(&app, &cookie).await;
906 let req = password_request(
907 &csrf,
908 &cookie,
909 "password123",
910 "newpassword456",
911 "newpassword456",
912 );
913 let resp = app.oneshot(req).await.unwrap();
914 assert_eq!(resp.status(), StatusCode::OK);
915
916 let session2 = ath.db().lookup_session(&token2).await.unwrap();
918 assert!(session2.is_none(), "old session should be invalidated");
919
920 let set_cookie = resp
922 .headers()
923 .get(header::SET_COOKIE)
924 .unwrap()
925 .to_str()
926 .unwrap();
927 assert!(set_cookie.contains("allowthem_session"));
928 }
929
930 #[tokio::test]
931 async fn post_password_new_cookie_authenticates() {
932 let (ath, config, cookie) = setup().await;
933 let app = test_app(ath.clone(), config.clone());
934 let csrf = get_csrf_token(&app, &cookie).await;
935 let req = password_request(
936 &csrf,
937 &cookie,
938 "password123",
939 "newpassword456",
940 "newpassword456",
941 );
942 let resp = app.oneshot(req).await.unwrap();
943
944 let set_cookie = resp
946 .headers()
947 .get(header::SET_COOKIE)
948 .unwrap()
949 .to_str()
950 .unwrap();
951 let new_token = parse_session_cookie(set_cookie, "allowthem_session")
952 .expect("new session cookie should be present");
953 let new_cookie = format!("allowthem_session={}", new_token.as_str());
954
955 let app2 = test_app(ath, config);
957 let req = Request::builder()
958 .uri("/settings")
959 .header(header::COOKIE, &new_cookie)
960 .body(Body::empty())
961 .unwrap();
962 let resp = app2.oneshot(req).await.unwrap();
963 assert_eq!(resp.status(), StatusCode::OK);
964 let html = body_string(resp).await;
965 assert!(html.contains("user@example.com"));
966 }
967
968 #[tokio::test]
969 async fn post_password_logs_audit() {
970 let (ath, config, cookie) = setup().await;
971 let app = test_app(ath.clone(), config);
972 let csrf = get_csrf_token(&app, &cookie).await;
973 let req = password_request(
974 &csrf,
975 &cookie,
976 "password123",
977 "newpassword456",
978 "newpassword456",
979 );
980 app.oneshot(req).await.unwrap();
981
982 let entries = ath.db().get_audit_log(None, 10, 0).await.unwrap();
983 let pw_change = entries
984 .iter()
985 .find(|e| e.event_type == AuditEvent::PasswordChange);
986 assert!(
987 pw_change.is_some(),
988 "PasswordChange audit event should be recorded"
989 );
990 }
991
992 #[tokio::test]
993 async fn post_password_requires_csrf() {
994 let (ath, config, cookie) = setup().await;
995 let app = test_app(ath, config);
996 let body = "current_password=pass&new_password=newpass123&new_password_confirm=newpass123";
997 let req = Request::builder()
998 .method("POST")
999 .uri("/settings/password")
1000 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1001 .header(header::COOKIE, &cookie)
1002 .body(Body::from(body))
1003 .unwrap();
1004 let resp = app.oneshot(req).await.unwrap();
1005 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1006 }
1007
1008 #[tokio::test]
1009 async fn post_password_oauth_only_user_shows_error() {
1010 let ath = AllowThemBuilder::new("sqlite::memory:")
1013 .cookie_secure(false)
1014 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
1015 .build()
1016 .await
1017 .unwrap();
1018 let templates = crate::browser_templates::build_default_browser_env();
1019
1020 let email = Email::new("oauth@example.com".into()).unwrap();
1021 let user = ath
1022 .db()
1023 .create_oauth_user(email, "google", "google-uid-123")
1024 .await
1025 .unwrap();
1026
1027 let token = generate_token();
1028 let token_hash = hash_token(&token);
1029 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
1030 ath.db()
1031 .create_session(user.id, token_hash, None, None, expires)
1032 .await
1033 .unwrap();
1034 let set_cookie = ath.session_cookie(&token);
1035 let cookie = set_cookie.split(';').next().unwrap().to_string();
1036
1037 let config = SettingsConfig {
1038 templates,
1039 is_production: false,
1040 };
1041
1042 let app = test_app(ath, config);
1043 let csrf = get_csrf_token(&app, &cookie).await;
1044 let req = password_request(
1045 &csrf,
1046 &cookie,
1047 "anypassword",
1048 "newpassword456",
1049 "newpassword456",
1050 );
1051 let resp = app.oneshot(req).await.unwrap();
1052 let html = body_string(resp).await;
1053 assert!(html.contains("Current password is incorrect"));
1054 }
1055}