Skip to main content

max / makenotwork

29.6 KB · 793 lines History Blame Raw
1 //! Authentication, session management, and account security.
2 //!
3 //! Passwords are hashed with Argon2id (random salt per hash). Sessions use
4 //! `tower-sessions` with ID regeneration on login (prevents fixation) and
5 //! full flush on logout. Each login creates a tracked session row in
6 //! `user_sessions` for remote revocation from the security dashboard.
7 //!
8 //! Two-factor authentication supports both TOTP (time-based one-time
9 //! passwords via `totp-rs`) and WebAuthn passkeys (via `webauthn-rs`).
10 //! Account lockout is enforced after repeated failed login attempts, with
11 //! progressive delays tracked by `failed_login_attempts` and `locked_until`
12 //! on the user row. New-device login notifications are sent via Postmark
13 //! when enabled.
14 //!
15 //! Extractors: [`AuthUser`] (required login), [`MaybeUserUnverified`] (optional,
16 //! no revocation check — public read-only pages only), [`MaybeUserVerified`]
17 //! (optional with revocation check — anywhere identity actually gates behavior),
18 //! [`AdminUser`] (admin-only, hides routes with 404).
19
20 use argon2::{
21 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
22 Algorithm, Argon2, Params, Version,
23 };
24 use axum::{
25 extract::FromRequestParts,
26 http::{header::HeaderMap, request::Parts},
27 };
28 use serde::{Deserialize, Serialize};
29 use sqlx::PgPool;
30 use tower_sessions::Session;
31
32 use std::time::Instant;
33
34 use crate::config::Config;
35 use crate::constants;
36 use crate::db::{self, UserId, UserSessionId, Username};
37 use crate::error::{AppError, ResultExt};
38 use crate::helpers::constant_time_compare;
39
40 /// Session key for storing user data
41 const USER_SESSION_KEY: &str = "user";
42 /// Session key for linking to the `user_sessions` tracking row.
43 pub const SESSION_TRACKING_KEY: &str = "session_tracking_id";
44
45 /// User data stored in session
46 #[derive(Clone, Debug, Serialize, Deserialize)]
47 pub struct SessionUser {
48 pub id: UserId,
49 pub username: Username,
50 pub email: String,
51 pub display_name: Option<String>,
52 #[serde(default)]
53 pub can_create_projects: bool,
54 #[serde(default)]
55 pub suspended: bool,
56 #[serde(default)]
57 pub is_admin: bool,
58 #[serde(default)]
59 pub is_fan_plus: bool,
60 #[serde(default)]
61 pub creator_tier: Option<db::CreatorTier>,
62 #[serde(default)]
63 pub deactivated: bool,
64 #[serde(default)]
65 pub is_sandbox: bool,
66 }
67
68 impl SessionUser {
69 /// Build a `SessionUser` from a DB user row + async lookups for fan_plus and creator_tier.
70 ///
71 /// Used by all login paths (password, passkey, 2FA, email link) except the join wizard
72 /// (which uses hardcoded defaults for a freshly created account).
73 pub async fn from_db_user(
74 user: db::DbUser,
75 pool: &sqlx::PgPool,
76 admin_user_id: Option<db::UserId>,
77 ) -> Self {
78 let suspended = user.is_suspended();
79 let deactivated = user.is_deactivated();
80 let is_admin = admin_user_id == Some(user.id);
81 let is_fan_plus = db::fan_plus::is_fan_plus_active(pool, user.id)
82 .await
83 .unwrap_or(false);
84 let creator_tier = db::creator_tiers::get_active_creator_tier(pool, user.id)
85 .await
86 .ok()
87 .flatten();
88 Self {
89 id: user.id,
90 username: user.username,
91 email: user.email.into_inner(),
92 display_name: user.display_name,
93 can_create_projects: user.can_create_projects,
94 suspended,
95 is_admin,
96 is_fan_plus,
97 creator_tier,
98 deactivated,
99 is_sandbox: user.is_sandbox,
100 }
101 }
102
103 /// Returns `Err(Forbidden)` if the user is a sandbox account.
104 /// Call at the top of routes that sandbox users must not access (Stripe, email, etc.).
105 pub fn check_not_sandbox(&self) -> Result<(), AppError> {
106 if self.is_sandbox {
107 Err(AppError::Forbidden)
108 } else {
109 Ok(())
110 }
111 }
112
113 /// Returns `Err(Forbidden)` if the user is suspended or deactivated.
114 /// Call at the top of write routes that suspended/deactivated users should not access.
115 pub fn check_not_suspended(&self) -> Result<(), AppError> {
116 if self.suspended || self.deactivated {
117 Err(AppError::Forbidden)
118 } else {
119 Ok(())
120 }
121 }
122 }
123
124 /// Extractor for authenticated users - returns error if not logged in.
125 ///
126 /// Specialized to `AppState` (not generic `S`) to access the DB pool for
127 /// session tracking validation. If the session's tracking row has been
128 /// deleted (revoked), the session is flushed and Unauthorized is returned.
129 /// Legacy sessions without a tracking ID are allowed through until they
130 /// expire naturally.
131 pub struct AuthUser(pub SessionUser);
132
133 impl FromRequestParts<crate::AppState> for AuthUser {
134 type Rejection = AppError;
135
136 async fn from_request_parts(
137 parts: &mut Parts,
138 state: &crate::AppState,
139 ) -> Result<Self, Self::Rejection> {
140 let session = parts
141 .extensions
142 .get::<Session>()
143 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
144
145 let user: SessionUser = session
146 .get(USER_SESSION_KEY)
147 .await
148 .context("session error")?
149 .ok_or(AppError::Unauthorized)?;
150
151 // Validate session tracking (skip for legacy sessions without tracking ID).
152 // Uses an in-memory cache to avoid hitting the DB on every request —
153 // if this session was validated within SESSION_TOUCH_CACHE_SECS, skip the query.
154 let mut user = user;
155 if let Ok(Some(tracking_id)) = session
156 .get::<UserSessionId>(SESSION_TRACKING_KEY)
157 .await
158 {
159 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
160 let cached = state.session_cache.get(&tracking_id)
161 .map(|entry| entry.elapsed() < cache_ttl)
162 .unwrap_or(false);
163
164 if !cached {
165 let result = match db::sessions::touch_session(&state.db, tracking_id).await {
166 Ok(r) => r,
167 Err(e) => {
168 tracing::warn!(error = ?e, "session touch failed, invalidating");
169 db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None }
170 }
171 };
172 if !result.valid {
173 state.session_cache.remove(&tracking_id);
174 let _ = session.flush().await;
175 return Err(AppError::Unauthorized);
176 }
177 // If the user's live DB state differs from the session, update it.
178 // touch_session returns suspended, can_create_projects, is_fan_plus,
179 // and creator_tier in a single query (no extra round-trips).
180 let live_tier: Option<db::CreatorTier> = result.creator_tier.as_deref().and_then(|s| s.parse().ok());
181 if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier {
182 user.suspended = result.suspended;
183 user.is_fan_plus = result.is_fan_plus;
184 user.can_create_projects = result.can_create_projects;
185 user.creator_tier = live_tier;
186 if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await {
187 tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
188 }
189 }
190 state.session_cache.insert(tracking_id, Instant::now());
191 }
192 }
193
194 // Record user_id in the current span so all downstream logs
195 // (DB queries, error handlers, etc.) include it automatically.
196 tracing::Span::current().record("user_id", tracing::field::display(&user.id));
197
198 Ok(AuthUser(user))
199 }
200 }
201
202 /// Extractor for optional authenticated users — returns None if not logged in.
203 ///
204 /// **DANGER — this extractor does NOT validate the session against the database.**
205 /// A revoked session (user clicked "log out everywhere", account suspended,
206 /// session row deleted) will still resolve to `Some(SessionUser)` here until
207 /// the cookie naturally expires. The name carries the warning: any handler
208 /// that uses this type accepts that consequence.
209 ///
210 /// Use ONLY for cheap anonymous-or-logged-in rendering on public read-only
211 /// pages where displaying stale identity is acceptable (blog views, docs,
212 /// discover feed). For any handler that:
213 /// - modifies data,
214 /// - gates paid content or downloads,
215 /// - issues OAuth tokens / grants,
216 /// - exposes account-private information,
217 /// use [`AuthUser`] (required login) or [`MaybeUserVerified`] (optional login
218 /// with revocation check) instead.
219 pub struct MaybeUserUnverified(pub Option<SessionUser>);
220
221 impl<S> FromRequestParts<S> for MaybeUserUnverified
222 where
223 S: Send + Sync,
224 {
225 type Rejection = AppError;
226
227 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
228 let session = parts
229 .extensions
230 .get::<Session>()
231 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
232
233 let user: Option<SessionUser> = session
234 .get(USER_SESSION_KEY)
235 .await
236 .context("session error")?;
237
238 // Short-circuit legacy sessions (USER_SESSION_KEY present without a
239 // SESSION_TRACKING_KEY) to anonymous. Without this, a pre-tracking
240 // session quietly survives `/logout-everywhere` — that sweep deletes
241 // user_sessions rows, but a legacy session has no row to delete and
242 // would keep rendering as logged-in on every Unverified extractor
243 // until the cookie naturally expires.
244 if user.is_some() {
245 let tracking: Option<UserSessionId> = session
246 .get(SESSION_TRACKING_KEY)
247 .await
248 .ok()
249 .flatten();
250 if tracking.is_none() {
251 return Ok(MaybeUserUnverified(None));
252 }
253 }
254
255 Ok(MaybeUserUnverified(user))
256 }
257 }
258
259 /// Extractor for optional authenticated users WITH revocation check.
260 ///
261 /// Like [`MaybeUserUnverified`] but runs the same session-tracking validation
262 /// as [`AuthUser`]: if the tracking row has been deleted (revoked) or the
263 /// account is suspended, the session is flushed and `None` is returned (the
264 /// request continues as anonymous rather than 401, since the handler chose
265 /// "optional auth"). Legacy sessions without a tracking ID pass through.
266 ///
267 /// Costs one cached `touch_session` query per request (TTL = `SESSION_TOUCH_CACHE_SECS`).
268 /// Prefer this over `MaybeUserUnverified` anywhere the identity actually gates
269 /// behavior — paid content access, OAuth flows, download grants, comments,
270 /// or anything that writes to the DB on behalf of the user.
271 pub struct MaybeUserVerified(pub Option<SessionUser>);
272
273 impl FromRequestParts<crate::AppState> for MaybeUserVerified {
274 type Rejection = AppError;
275
276 async fn from_request_parts(
277 parts: &mut Parts,
278 state: &crate::AppState,
279 ) -> Result<Self, Self::Rejection> {
280 let session = parts
281 .extensions
282 .get::<Session>()
283 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
284
285 let Some(mut user): Option<SessionUser> = session
286 .get(USER_SESSION_KEY)
287 .await
288 .context("session error")?
289 else {
290 return Ok(MaybeUserVerified(None));
291 };
292
293 if let Ok(Some(tracking_id)) = session
294 .get::<UserSessionId>(SESSION_TRACKING_KEY)
295 .await
296 {
297 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
298 let cached = state.session_cache.get(&tracking_id)
299 .map(|entry| entry.elapsed() < cache_ttl)
300 .unwrap_or(false);
301
302 if !cached {
303 let result = match db::sessions::touch_session(&state.db, tracking_id).await {
304 Ok(r) => r,
305 Err(e) => {
306 tracing::warn!(error = ?e, "session touch failed in MaybeUserVerified, treating as anonymous");
307 db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None }
308 }
309 };
310 if !result.valid {
311 state.session_cache.remove(&tracking_id);
312 let _ = session.flush().await;
313 return Ok(MaybeUserVerified(None));
314 }
315 let live_tier: Option<db::CreatorTier> = result.creator_tier.as_deref().and_then(|s| s.parse().ok());
316 if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier {
317 user.suspended = result.suspended;
318 user.is_fan_plus = result.is_fan_plus;
319 user.can_create_projects = result.can_create_projects;
320 user.creator_tier = live_tier;
321 if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await {
322 tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
323 }
324 }
325 state.session_cache.insert(tracking_id, Instant::now());
326 }
327 }
328
329 tracing::Span::current().record("user_id", tracing::field::display(&user.id));
330
331 Ok(MaybeUserVerified(Some(user)))
332 }
333 }
334
335 /// Extractor for admin users - returns NotFound (hides admin routes) if not admin.
336 ///
337 /// Combines `AuthUser` session check with `require_admin` config check into a
338 /// single type-safe extractor, eliminating per-handler `require_admin()` calls.
339 pub struct AdminUser(pub SessionUser);
340
341 impl FromRequestParts<crate::AppState> for AdminUser {
342 type Rejection = AppError;
343
344 async fn from_request_parts(
345 parts: &mut Parts,
346 state: &crate::AppState,
347 ) -> Result<Self, Self::Rejection> {
348 let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?;
349 require_admin(&user, &state.config)?;
350 Ok(AdminUser(user))
351 }
352 }
353
354 /// Extractor for internal service-to-service auth (CLI SSH server → MNW API).
355 ///
356 /// Validates `Authorization: Bearer {token}` against `config.cli_service_token`.
357 /// Returns 401 if the token is missing/invalid, 503 if the token is not configured.
358 pub struct ServiceAuth;
359
360 impl FromRequestParts<crate::AppState> for ServiceAuth {
361 type Rejection = AppError;
362
363 async fn from_request_parts(
364 parts: &mut Parts,
365 state: &crate::AppState,
366 ) -> Result<Self, Self::Rejection> {
367 let expected = state.config.cli_service_token.as_deref().ok_or_else(|| {
368 AppError::ServiceUnavailable("Internal API not configured".to_string())
369 })?;
370
371 let header = parts
372 .headers
373 .get("authorization")
374 .and_then(|v| v.to_str().ok())
375 .and_then(|v| v.strip_prefix("Bearer "))
376 .ok_or(AppError::Unauthorized)?;
377
378 if !constant_time_compare(header, expected) {
379 return Err(AppError::Unauthorized);
380 }
381
382 Ok(ServiceAuth)
383 }
384 }
385
386 /// Hash a password using Argon2id.
387 ///
388 /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms).
389 /// Verification auto-detects params from the hash string, so no feature flag needed there.
390 pub fn hash_password(password: &str) -> Result<String, AppError> {
391 let salt = SaltString::generate(&mut OsRng);
392 #[cfg(feature = "fast-tests")]
393 let params = Params::new(8 * 1024, 1, 1, None)
394 .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?;
395 #[cfg(not(feature = "fast-tests"))]
396 let params = Params::new(46 * 1024, 2, 1, None)
397 .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?;
398 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
399
400 let hash = argon2
401 .hash_password(password.as_bytes(), &salt)
402 .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing: {e}")))?;
403
404 Ok(hash.to_string())
405 }
406
407 /// Verify a password against a stored PHC-encoded Argon2 hash.
408 ///
409 /// Derives the verifier from the stored hash's own algorithm/version/params
410 /// rather than relying on `Argon2::default()`. The two are functionally
411 /// equivalent today (the `PasswordVerifier` trait reads parameters from
412 /// the parsed hash, not the instance) but explicit derivation pins our
413 /// boundary: this function only verifies Argon2 family hashes, anything
414 /// else fails out at `Algorithm::try_from`. Forward-compatible with a
415 /// future algorithm migration — when one lands, add a dispatch table
416 /// instead of swapping the default instance under the verifier's feet.
417 pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
418 let parsed_hash = PasswordHash::new(hash)
419 .map_err(|e| AppError::Internal(anyhow::anyhow!("parse password hash: {e}")))?;
420
421 let algorithm = Algorithm::try_from(parsed_hash.algorithm)
422 .map_err(|e| AppError::Internal(anyhow::anyhow!("unexpected password hash algorithm: {e}")))?;
423 let version = parsed_hash
424 .version
425 .map(Version::try_from)
426 .transpose()
427 .map_err(|e| AppError::Internal(anyhow::anyhow!("unexpected password hash version: {e}")))?
428 .unwrap_or(Version::V0x13);
429 let params = Params::try_from(&parsed_hash)
430 .map_err(|e| AppError::Internal(anyhow::anyhow!("parse password hash params: {e}")))?;
431
432 Ok(Argon2::new(algorithm, version, params)
433 .verify_password(password.as_bytes(), &parsed_hash)
434 .is_ok())
435 }
436
437 /// Store user in session with session regeneration to prevent fixation attacks
438 #[tracing::instrument(skip_all, fields(user_id = %user.id))]
439 pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> {
440 // Regenerate session ID to prevent session fixation attacks
441 // This creates a new session ID while preserving session data
442 session
443 .cycle_id()
444 .await
445 .context("session cycle")?;
446
447 // Regenerate CSRF token so pre-auth tokens can't be used post-auth
448 let new_csrf = crate::csrf::generate_token();
449 session
450 .insert(crate::csrf::CSRF_SESSION_KEY, &new_csrf)
451 .await
452 .context("csrf token insert")?;
453
454 session
455 .insert(USER_SESSION_KEY, user)
456 .await
457 .context("session insert")?;
458 Ok(())
459 }
460
461 /// Destroy entire session on logout to prevent session reuse
462 #[tracing::instrument(skip_all)]
463 pub async fn logout_user(session: &Session) -> Result<(), AppError> {
464 // Flush the entire session to destroy all data and invalidate session ID
465 session
466 .flush()
467 .await
468 .context("session flush")?;
469 Ok(())
470 }
471
472 /// Record a new session in `user_sessions` and store the tracking ID in session data.
473 /// Call this after `login_user()` in every login path.
474 #[tracing::instrument(skip_all, fields(user_id = %user_id))]
475 pub async fn track_session(
476 session: &Session,
477 pool: &PgPool,
478 user_id: UserId,
479 headers: &HeaderMap,
480 ) -> Result<(), AppError> {
481 let user_agent = headers
482 .get("user-agent")
483 .and_then(|v| v.to_str().ok())
484 .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>());
485
486 let ip = crate::helpers::extract_client_ip(headers);
487
488 let tracking_id =
489 db::sessions::create_user_session(pool, user_id, user_agent.as_deref(), ip.as_deref()).await?;
490
491 session
492 .insert(SESSION_TRACKING_KEY, tracking_id)
493 .await
494 .context("session insert")?;
495
496 Ok(())
497 }
498
499 /// Send a new-device login notification if the user has other active sessions.
500 ///
501 /// Fire-and-forget — spawns a background task. Only sends if the user has opted in
502 /// and has more than one active session (meaning this is a new device).
503 pub async fn maybe_send_login_notification(
504 state: &crate::AppState,
505 user_id: UserId,
506 email: &str,
507 display_name: Option<&str>,
508 enabled: bool,
509 headers: &HeaderMap,
510 ) {
511 if !enabled {
512 return;
513 }
514 let session_count = match db::sessions::count_user_sessions(&state.db, user_id).await {
515 Ok(n) => n,
516 Err(e) => {
517 tracing::warn!("Failed to count sessions for login notification: {e}");
518 return;
519 }
520 };
521 if session_count <= 1 {
522 return;
523 }
524 let user_agent = headers
525 .get("user-agent")
526 .and_then(|v| v.to_str().ok())
527 .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>());
528 let ip = crate::helpers::extract_client_ip(headers);
529 let unsub_url = crate::email::generate_unsubscribe_url(
530 &state.config.host_url,
531 user_id,
532 crate::email::UnsubscribeAction::Login,
533 &user_id.to_string(),
534 &state.config.signing_secret,
535 );
536 let email = email.to_string();
537 let display_name = display_name.map(String::from);
538 crate::helpers::spawn_email!(state, "login notification", |email_client| {
539 email_client.send_new_login_notification(
540 &email,
541 display_name.as_deref(),
542 user_agent.as_deref(),
543 ip.as_deref(),
544 Some(&unsub_url),
545 )
546 });
547 }
548
549 /// Check if a password appears in the HaveIBeenPwned breached passwords database.
550 /// Uses k-anonymity: only the first 5 characters of the SHA-1 hash are sent.
551 /// Returns Some(count) if breached, None if clean or API unavailable.
552 pub async fn check_password_breach(password: &str) -> Option<u64> {
553 use sha1::{Sha1, Digest};
554
555 let hash = hex::encode(Sha1::digest(password.as_bytes())).to_uppercase();
556 let (prefix, suffix) = hash.split_at(5);
557
558 let url = format!("https://api.pwnedpasswords.com/range/{}", prefix);
559 let response = reqwest::Client::new()
560 .get(&url)
561 .header("User-Agent", "MakeNotWork-Security-Check")
562 .header("Add-Padding", "true")
563 .timeout(std::time::Duration::from_secs(3))
564 .send()
565 .await
566 .ok()?
567 .text()
568 .await
569 .ok()?;
570
571 for line in response.lines() {
572 let mut parts = line.splitn(2, ':');
573 if let (Some(hash_suffix), Some(count)) = (parts.next(), parts.next())
574 && hash_suffix.trim() == suffix
575 {
576 return count.trim().parse().ok();
577 }
578 }
579
580 None
581 }
582
583 /// Check if a user is the admin. Returns NotFound to hide admin routes from non-admins.
584 pub fn require_admin(user: &SessionUser, config: &Config) -> Result<(), AppError> {
585 match config.admin_user_id {
586 Some(admin_id) if admin_id == user.id => Ok(()),
587 _ => Err(AppError::NotFound),
588 }
589 }
590
591 #[cfg(test)]
592 mod tests {
593 use super::*;
594
595 #[test]
596 fn hash_password_produces_valid_hash() {
597 let hash = hash_password("test_password_123").unwrap();
598 // Argon2 hashes start with $argon2
599 assert!(hash.starts_with("$argon2"));
600 }
601
602 #[test]
603 fn verify_password_correct() {
604 let hash = hash_password("correct_horse").unwrap();
605 assert!(verify_password("correct_horse", &hash).unwrap());
606 }
607
608 #[test]
609 fn verify_password_wrong() {
610 let hash = hash_password("correct_horse").unwrap();
611 assert!(!verify_password("wrong_horse", &hash).unwrap());
612 }
613
614 #[test]
615 fn hash_password_different_each_time() {
616 let h1 = hash_password("same_password").unwrap();
617 let h2 = hash_password("same_password").unwrap();
618 // Different salts should produce different hashes
619 assert_ne!(h1, h2);
620 }
621
622 #[test]
623 fn require_admin_with_admin_id() {
624 let user = SessionUser {
625 id: "00000000-0000-0000-0000-000000000001".parse::<UserId>().unwrap(),
626 username: Username::from_trusted("admin".to_string()),
627 email: "admin@example.com".to_string(),
628 display_name: None,
629 can_create_projects: true,
630 suspended: false,
631 is_admin: true,
632 is_fan_plus: false,
633 creator_tier: None,
634 deactivated: false,
635 is_sandbox: false,
636 };
637 let config = Config {
638 host: "127.0.0.1".parse().unwrap(),
639 port: 3000,
640 database_url: "postgres://test".to_string(),
641 host_url: std::sync::Arc::from("http://localhost:3000"),
642 signing_secret: "secret".to_string(),
643 storage: None,
644 synckit_storage: None,
645 stripe: None,
646 admin_user_id: Some(user.id),
647 synckit_jwt_secret: None,
648 scan: None,
649 git_repos_path: None,
650 postmark_webhook_token: None,
651 postmark_broadcast_webhook_token: None,
652 git_ssh_host: None,
653 mt_base_url: None,
654 fan_plus_price_id: None,
655 creator_tier_prices: std::collections::HashMap::new(),
656 creator_tier_annual_prices: std::collections::HashMap::new(),
657 creator_tier_founder_prices: std::collections::HashMap::new(),
658 creator_tier_founder_annual_prices: std::collections::HashMap::new(),
659 creator_founder_window_open: false,
660 build_trigger_token: None,
661 build_host_linux: None,
662 build_host_darwin: None,
663 cdn_base_url: None,
664 postmark_inbound_webhook_token: None,
665 internal_shared_secret: None,
666 cli_service_token: None,
667 wam_url: None,
668 };
669 assert!(require_admin(&user, &config).is_ok());
670 }
671
672 #[tokio::test]
673 #[ignore] // Requires network access — run manually
674 async fn check_password_breach_known_breached() {
675 let result = check_password_breach("password").await;
676 assert!(result.is_some());
677 assert!(result.unwrap() > 0);
678 }
679
680 #[tokio::test]
681 #[ignore] // Requires network access — run manually
682 async fn check_password_breach_unknown() {
683 // A random 64-char string should not appear in any breach database
684 let random_pw = "xK9m2Qp7vL4nR8wJ3sY6dF1gH5bT0cU9eA2iO7lN4mP8qW3rX6zV1yB5jD0fG";
685 let result = check_password_breach(random_pw).await;
686 assert!(result.is_none());
687 }
688
689 #[test]
690 fn require_admin_without_admin_id() {
691 let user = SessionUser {
692 id: UserId::new(),
693 username: Username::from_trusted("notadmin".to_string()),
694 email: "user@example.com".to_string(),
695 display_name: None,
696 can_create_projects: false,
697 suspended: false,
698 is_admin: false,
699 is_fan_plus: false,
700 creator_tier: None,
701 deactivated: false,
702 is_sandbox: false,
703 };
704 let config = Config {
705 host: "127.0.0.1".parse().unwrap(),
706 port: 3000,
707 database_url: "postgres://test".to_string(),
708 host_url: std::sync::Arc::from("http://localhost:3000"),
709 signing_secret: "secret".to_string(),
710 storage: None,
711 synckit_storage: None,
712 stripe: None,
713 admin_user_id: None,
714 synckit_jwt_secret: None,
715 scan: None,
716 git_repos_path: None,
717 postmark_webhook_token: None,
718 postmark_broadcast_webhook_token: None,
719 git_ssh_host: None,
720 mt_base_url: None,
721 fan_plus_price_id: None,
722 creator_tier_prices: std::collections::HashMap::new(),
723 creator_tier_annual_prices: std::collections::HashMap::new(),
724 creator_tier_founder_prices: std::collections::HashMap::new(),
725 creator_tier_founder_annual_prices: std::collections::HashMap::new(),
726 creator_founder_window_open: false,
727 build_trigger_token: None,
728 build_host_linux: None,
729 build_host_darwin: None,
730 cdn_base_url: None,
731 postmark_inbound_webhook_token: None,
732 internal_shared_secret: None,
733 cli_service_token: None,
734 wam_url: None,
735 };
736 assert!(require_admin(&user, &config).is_err());
737 }
738
739 // ── Guard function tests ──
740
741 fn make_user(is_sandbox: bool, suspended: bool, deactivated: bool) -> SessionUser {
742 SessionUser {
743 id: UserId::new(),
744 username: Username::from_trusted("testuser".to_string()),
745 email: "test@example.com".to_string(),
746 display_name: None,
747 can_create_projects: false,
748 suspended,
749 is_admin: false,
750 is_fan_plus: false,
751 creator_tier: None,
752 deactivated,
753 is_sandbox,
754 }
755 }
756
757 #[test]
758 fn check_not_sandbox_allows_normal_user() {
759 let user = make_user(false, false, false);
760 assert!(user.check_not_sandbox().is_ok());
761 }
762
763 #[test]
764 fn check_not_sandbox_blocks_sandbox() {
765 let user = make_user(true, false, false);
766 assert!(user.check_not_sandbox().is_err());
767 }
768
769 #[test]
770 fn check_not_suspended_allows_normal_user() {
771 let user = make_user(false, false, false);
772 assert!(user.check_not_suspended().is_ok());
773 }
774
775 #[test]
776 fn check_not_suspended_blocks_suspended() {
777 let user = make_user(false, true, false);
778 assert!(user.check_not_suspended().is_err());
779 }
780
781 #[test]
782 fn check_not_suspended_blocks_deactivated() {
783 let user = make_user(false, false, true);
784 assert!(user.check_not_suspended().is_err());
785 }
786
787 #[test]
788 fn check_not_suspended_blocks_both() {
789 let user = make_user(false, true, true);
790 assert!(user.check_not_suspended().is_err());
791 }
792 }
793