Skip to main content

max / makenotwork

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