Skip to main content

max / makenotwork

44.0 KB · 1177 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 Algorithm, Argon2, Params, Version,
22 password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
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
132 .get::<SessionUser>(USER_SESSION_KEY)
133 .await
134 .ok()
135 .flatten()
136 }
137
138 /// Extractor for authenticated users - returns error if not logged in.
139 ///
140 /// Specialized to `AppState` (not generic `S`) to access the DB pool for
141 /// session tracking validation. If the session's tracking row has been
142 /// deleted (revoked), the session is flushed and Unauthorized is returned.
143 /// Legacy sessions without a tracking ID are allowed through until they
144 /// expire naturally.
145 pub struct AuthUser(pub SessionUser);
146
147 impl FromRequestParts<crate::AppState> for AuthUser {
148 type Rejection = AppError;
149
150 async fn from_request_parts(
151 parts: &mut Parts,
152 state: &crate::AppState,
153 ) -> Result<Self, Self::Rejection> {
154 let session = parts
155 .extensions
156 .get::<Session>()
157 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
158
159 let user: SessionUser = session
160 .get(USER_SESSION_KEY)
161 .await
162 .context("session error")?
163 .ok_or(AppError::Unauthorized)?;
164
165 // Every live session carries a tracking id, set at login by
166 // `track_session`. A session with USER_SESSION_KEY but no
167 // SESSION_TRACKING_KEY is a legacy pre-tracking session that cannot be
168 // revoked, "log out everywhere", suspend, and password-change all act
169 // on `user_sessions` rows it doesn't have. Refuse it (force re-login)
170 // rather than trust an unrevocable session (Run 20 Security). Matches
171 // the short-circuit `MaybeUserUnverified` already applies.
172 let mut user = user;
173 let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await else {
174 let _ = session.flush().await;
175 return Err(AppError::Unauthorized);
176 };
177
178 // Validate the tracking row. Uses an in-memory cache to avoid hitting
179 // the DB on every request, if this session was validated within
180 // SESSION_TOUCH_CACHE_SECS, skip the query.
181 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
182 let cached = state
183 .caches
184 .session_cache
185 .get(&tracking_id)
186 .is_some_and(|entry| entry.elapsed() < cache_ttl);
187
188 if !cached {
189 let result = match db::sessions::touch_session(&state.db, tracking_id).await {
190 Ok(r) => r,
191 Err(e) => {
192 tracing::warn!(error = ?e, "session touch failed, invalidating");
193 db::sessions::TouchResult {
194 valid: false,
195 suspended: false,
196 can_create_projects: false,
197 is_fan_plus: false,
198 creator_tier: None,
199 }
200 }
201 };
202 if !result.valid {
203 state.caches.session_cache.remove(&tracking_id);
204 let _ = session.flush().await;
205 return Err(AppError::Unauthorized);
206 }
207 // If the user's live DB state differs from the session, update it.
208 // touch_session returns suspended, can_create_projects, is_fan_plus,
209 // and creator_tier in a single query (no extra round-trips).
210 let live_tier: Option<db::CreatorTier> =
211 result.creator_tier.as_deref().and_then(|s| s.parse().ok());
212 if user.suspended != result.suspended
213 || user.is_fan_plus != result.is_fan_plus
214 || user.can_create_projects != result.can_create_projects
215 || user.creator_tier != live_tier
216 {
217 user.suspended = result.suspended;
218 user.is_fan_plus = result.is_fan_plus;
219 user.can_create_projects = result.can_create_projects;
220 user.creator_tier = live_tier;
221 if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await {
222 tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
223 }
224 }
225 state
226 .caches
227 .session_cache
228 .insert(tracking_id, Instant::now());
229 }
230
231 // Record user_id in the current span so all downstream logs
232 // (DB queries, error handlers, etc.) include it automatically.
233 tracing::Span::current().record("user_id", tracing::field::display(&user.id));
234
235 Ok(AuthUser(user))
236 }
237 }
238
239 /// Extractor for optional authenticated users, returns None if not logged in.
240 ///
241 /// **DANGER, this extractor does NOT validate the session against the database.**
242 /// A revoked session (user clicked "log out everywhere", account suspended,
243 /// session row deleted) will still resolve to `Some(SessionUser)` here until
244 /// the cookie naturally expires. The name carries the warning: any handler
245 /// that uses this type accepts that consequence.
246 ///
247 /// Use ONLY for cheap anonymous-or-logged-in rendering on public read-only
248 /// pages where displaying stale identity is acceptable (blog views, docs,
249 /// discover feed). For any handler that:
250 /// - modifies data,
251 /// - gates paid content or downloads,
252 /// - issues OAuth tokens / grants,
253 /// - exposes account-private information,
254 ///
255 /// use [`AuthUser`] (required login) or [`MaybeUserVerified`] (optional login
256 /// with revocation check) instead.
257 pub struct MaybeUserUnverified(pub Option<SessionUser>);
258
259 impl<S> FromRequestParts<S> for MaybeUserUnverified
260 where
261 S: Send + Sync,
262 {
263 type Rejection = AppError;
264
265 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
266 let session = parts
267 .extensions
268 .get::<Session>()
269 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
270
271 let user: Option<SessionUser> = session
272 .get(USER_SESSION_KEY)
273 .await
274 .context("session error")?;
275
276 // Short-circuit legacy sessions (USER_SESSION_KEY present without a
277 // SESSION_TRACKING_KEY) to anonymous. Without this, a pre-tracking
278 // session quietly survives `/logout-everywhere`, that sweep deletes
279 // user_sessions rows, but a legacy session has no row to delete and
280 // would keep rendering as logged-in on every Unverified extractor
281 // until the cookie naturally expires.
282 if user.is_some() {
283 let tracking: Option<UserSessionId> =
284 session.get(SESSION_TRACKING_KEY).await.ok().flatten();
285 if tracking.is_none() {
286 return Ok(MaybeUserUnverified(None));
287 }
288 }
289
290 Ok(MaybeUserUnverified(user))
291 }
292 }
293
294 /// Extractor for optional authenticated users WITH revocation check.
295 ///
296 /// Like [`MaybeUserUnverified`] but runs the same session-tracking validation
297 /// as [`AuthUser`]: if the tracking row has been deleted (revoked) or the
298 /// account is suspended, the session is flushed and `None` is returned (the
299 /// request continues as anonymous rather than 401, since the handler chose
300 /// "optional auth"). Legacy sessions without a tracking ID pass through.
301 ///
302 /// Costs one cached `touch_session` query per request (TTL = `SESSION_TOUCH_CACHE_SECS`).
303 /// Prefer this over `MaybeUserUnverified` anywhere the identity actually gates
304 /// behavior, paid content access, OAuth flows, download grants, comments,
305 /// or anything that writes to the DB on behalf of the user.
306 pub struct MaybeUserVerified(pub Option<SessionUser>);
307
308 impl FromRequestParts<crate::AppState> for MaybeUserVerified {
309 type Rejection = AppError;
310
311 async fn from_request_parts(
312 parts: &mut Parts,
313 state: &crate::AppState,
314 ) -> Result<Self, Self::Rejection> {
315 let session = parts
316 .extensions
317 .get::<Session>()
318 .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?;
319
320 let Some(mut user): Option<SessionUser> = session
321 .get(USER_SESSION_KEY)
322 .await
323 .context("session error")?
324 else {
325 return Ok(MaybeUserVerified(None));
326 };
327
328 // A session with USER_SESSION_KEY but no SESSION_TRACKING_KEY is a legacy
329 // pre-tracking session that can't be revoked; treat it as anonymous
330 // rather than trust it (Run 20 Security), matching `AuthUser` and
331 // `MaybeUserUnverified`.
332 let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await else {
333 let _ = session.flush().await;
334 return Ok(MaybeUserVerified(None));
335 };
336
337 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
338 let cached = state
339 .caches
340 .session_cache
341 .get(&tracking_id)
342 .is_some_and(|entry| entry.elapsed() < cache_ttl);
343
344 if !cached {
345 let result = match db::sessions::touch_session(&state.db, tracking_id).await {
346 Ok(r) => r,
347 Err(e) => {
348 tracing::warn!(error = ?e, "session touch failed in MaybeUserVerified, treating as anonymous");
349 db::sessions::TouchResult {
350 valid: false,
351 suspended: false,
352 can_create_projects: false,
353 is_fan_plus: false,
354 creator_tier: None,
355 }
356 }
357 };
358 if !result.valid {
359 state.caches.session_cache.remove(&tracking_id);
360 let _ = session.flush().await;
361 return Ok(MaybeUserVerified(None));
362 }
363 let live_tier: Option<db::CreatorTier> =
364 result.creator_tier.as_deref().and_then(|s| s.parse().ok());
365 if user.suspended != result.suspended
366 || user.is_fan_plus != result.is_fan_plus
367 || user.can_create_projects != result.can_create_projects
368 || user.creator_tier != live_tier
369 {
370 user.suspended = result.suspended;
371 user.is_fan_plus = result.is_fan_plus;
372 user.can_create_projects = result.can_create_projects;
373 user.creator_tier = live_tier;
374 if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await {
375 tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
376 }
377 }
378 state
379 .caches
380 .session_cache
381 .insert(tracking_id, Instant::now());
382 }
383
384 tracing::Span::current().record("user_id", tracing::field::display(&user.id));
385
386 Ok(MaybeUserVerified(Some(user)))
387 }
388 }
389
390 /// Proof that an admin identity was established by the [`AdminUser`] extractor.
391 ///
392 /// The inner `UserId` is private to this module and the only public constructor
393 /// is [`AdminUser::admin_id`], so an `AdminId` cannot exist without having passed
394 /// the `require_admin` gate. DB writers that stamp an actor (`moderation_actions`,
395 /// `report.resolved_by`) take `AdminId` instead of a bare `UserId`, making a
396 /// forged or caller-supplied admin id un-constructible at the type level rather
397 /// than relying on every route to remember the guard (ultra-fuzz Run 11 Sec M2).
398 #[derive(Clone, Copy, Debug)]
399 pub struct AdminId(UserId);
400
401 impl AdminId {
402 /// The witnessed admin user id, for binding into a query.
403 pub fn get(self) -> UserId {
404 self.0
405 }
406
407 /// Mint an `AdminId` from the configured `ADMIN_USER_ID` for out-of-band admin
408 /// contexts that have no HTTP session, specifically the `mnw-admin` CLI, which
409 /// loads the same server env. Returns `None` when no admin is configured.
410 ///
411 /// This is the only constructor besides [`AdminUser::admin_id`], and it is
412 /// gated on the exact same config value that [`require_admin`] checks, so it
413 /// cannot attribute a moderation action to a non-admin, preserving the
414 /// forgery-proof invariant while letting a headless admin tool stamp the audit
415 /// trail with the real actor instead of skipping it.
416 pub fn from_config(config: &crate::config::Config) -> Option<Self> {
417 config.admin_user_id.map(AdminId)
418 }
419 }
420
421 /// Extractor for admin users - returns NotFound (hides admin routes) if not admin.
422 ///
423 /// Combines `AuthUser` session check with `require_admin` config check into a
424 /// single type-safe extractor, eliminating per-handler `require_admin()` calls.
425 pub struct AdminUser(pub SessionUser);
426
427 impl AdminUser {
428 /// Mint the [`AdminId`] witness for this verified admin. The only way to
429 /// obtain an `AdminId`, its private field can't be constructed elsewhere.
430 pub fn admin_id(&self) -> AdminId {
431 AdminId(self.0.id)
432 }
433
434 /// The admin's plain `UserId`, for tracing/display (not a write witness).
435 pub fn id(&self) -> UserId {
436 self.0.id
437 }
438 }
439
440 impl FromRequestParts<crate::AppState> for AdminUser {
441 type Rejection = AppError;
442
443 async fn from_request_parts(
444 parts: &mut Parts,
445 state: &crate::AppState,
446 ) -> Result<Self, Self::Rejection> {
447 let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?;
448 require_admin(&user, &state.config)?;
449 Ok(AdminUser(user))
450 }
451 }
452
453 /// Extractor for internal service-to-service auth (CLI SSH server → MNW API).
454 ///
455 /// Validates `Authorization: Bearer {token}` against `config.integrations.cli_service_token`.
456 /// Returns 401 if the token is missing/invalid, 503 if the token is not configured.
457 pub struct ServiceAuth;
458
459 impl FromRequestParts<crate::AppState> for ServiceAuth {
460 type Rejection = AppError;
461
462 async fn from_request_parts(
463 parts: &mut Parts,
464 state: &crate::AppState,
465 ) -> Result<Self, Self::Rejection> {
466 let expected = state
467 .config
468 .integrations
469 .cli_service_token
470 .as_deref()
471 .ok_or_else(|| {
472 AppError::ServiceUnavailable("Internal API not configured".to_string())
473 })?;
474
475 let header = parts
476 .headers
477 .get("authorization")
478 .and_then(|v| v.to_str().ok())
479 .and_then(|v| v.strip_prefix("Bearer "))
480 .ok_or(AppError::Unauthorized)?;
481
482 if !constant_time_compare(header, expected) {
483 return Err(AppError::Unauthorized);
484 }
485
486 Ok(ServiceAuth)
487 }
488 }
489
490 /// Extractor for inbound infra-alert ingestion (PoM / MT monitoring agents →
491 /// `POST /api/internal/alerts`).
492 ///
493 /// Validates `Authorization: Bearer {token}` against
494 /// `config.integrations.alerts_ingest_token`. Returns 401 if the token is
495 /// missing/invalid, 503 if the token is not configured. Deliberately keyed on a
496 /// token separate from `ServiceAuth`'s `cli_service_token`: these agents run on
497 /// other hosts (astra, prod), so a leak there must not grant CLI internal-API
498 /// access.
499 pub struct AlertsAuth;
500
501 impl FromRequestParts<crate::AppState> for AlertsAuth {
502 type Rejection = AppError;
503
504 async fn from_request_parts(
505 parts: &mut Parts,
506 state: &crate::AppState,
507 ) -> Result<Self, Self::Rejection> {
508 let expected = state
509 .config
510 .integrations
511 .alerts_ingest_token
512 .as_deref()
513 .ok_or_else(|| {
514 AppError::ServiceUnavailable("Alert ingestion not configured".to_string())
515 })?;
516
517 let header = parts
518 .headers
519 .get("authorization")
520 .and_then(|v| v.to_str().ok())
521 .and_then(|v| v.strip_prefix("Bearer "))
522 .ok_or(AppError::Unauthorized)?;
523
524 if !constant_time_compare(header, expected) {
525 return Err(AppError::Unauthorized);
526 }
527
528 Ok(AlertsAuth)
529 }
530 }
531
532 /// The SSH-authenticated user identity behind an internal-API request.
533 ///
534 /// Sourced from the `X-MNW-Actor` header, a signed assertion the server mints
535 /// during `ssh-key-lookup` (after authenticating the user by SSH key) and the
536 /// CLI forwards. Because the assertion is keyed by the server-only
537 /// `signing_secret`, a leaked `ServiceAuth` token cannot forge one for another
538 /// user, so internal handlers derive identity from this, never from a
539 /// caller-supplied `user_id` field. Handlers use `actor.user_id()` for scoping
540 /// and `actor.ensure_owns(resource.user_id)?` for ownership checks.
541 pub struct InternalActor(pub UserId);
542
543 impl InternalActor {
544 pub fn user_id(&self) -> UserId {
545 self.0
546 }
547
548 /// Reject with 403 unless the asserted identity owns the resource.
549 pub fn ensure_owns(&self, owner: UserId) -> Result<(), AppError> {
550 if self.0 == owner {
551 Ok(())
552 } else {
553 Err(AppError::Forbidden)
554 }
555 }
556 }
557
558 impl FromRequestParts<crate::AppState> for InternalActor {
559 type Rejection = AppError;
560
561 async fn from_request_parts(
562 parts: &mut Parts,
563 state: &crate::AppState,
564 ) -> Result<Self, Self::Rejection> {
565 let token = parts
566 .headers
567 .get("x-mnw-actor")
568 .and_then(|v| v.to_str().ok())
569 .ok_or(AppError::Unauthorized)?;
570
571 let now = chrono::Utc::now().timestamp();
572 let user_id =
573 crate::crypto::verify_internal_actor_token(token, &state.config.signing_secret, now)
574 .ok_or(AppError::Unauthorized)?;
575
576 Ok(InternalActor(user_id))
577 }
578 }
579
580 /// Hash a password using Argon2id.
581 ///
582 /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms).
583 /// Verification auto-detects params from the hash string, so no feature flag needed there.
584 /// Synchronous Argon2id hash. CPU-bound (hundreds of ms); do NOT call from an
585 /// async handler, use [`hash_password_async`], which runs this on a blocking
586 /// thread so a burst of signups can't starve the Tokio worker pool. The sync
587 /// form remains `pub` only for one-time `DUMMY_HASH` initializers and
588 /// test/integration fixtures that seed password hashes off the request path.
589 pub fn hash_password(password: &str) -> Result<String, AppError> {
590 let salt = SaltString::generate(&mut OsRng);
591 #[cfg(feature = "fast-tests")]
592 let params = Params::new(8 * 1024, 1, 1, None)
593 .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?;
594 #[cfg(not(feature = "fast-tests"))]
595 let params = Params::new(46 * 1024, 2, 1, None)
596 .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?;
597 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
598
599 let hash = argon2
600 .hash_password(password.as_bytes(), &salt)
601 .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing: {e}")))?;
602
603 Ok(hash.to_string())
604 }
605
606 /// Verify a password against a stored PHC-encoded Argon2 hash.
607 ///
608 /// Derives the verifier from the stored hash's own algorithm/version/params
609 /// rather than relying on `Argon2::default()`. The two are functionally
610 /// equivalent today (the `PasswordVerifier` trait reads parameters from
611 /// the parsed hash, not the instance) but explicit derivation pins our
612 /// boundary: this function only verifies Argon2 family hashes, anything
613 /// else fails out at `Algorithm::try_from`. Forward-compatible with a
614 /// future algorithm migration, when one lands, add a dispatch table
615 /// instead of swapping the default instance under the verifier's feet.
616 ///
617 /// CPU-bound (hundreds of ms); do NOT call from an async handler, use
618 /// [`verify_password_async`], which runs this on a blocking thread so concurrent
619 /// logins can't starve the Tokio worker pool. Kept `pub(crate)` for the async
620 /// wrapper, the timing-equalizer dummy verifies, and tests.
621 pub(crate) fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
622 // A stored hash that won't parse (corruption, or a non-Argon2 algorithm we
623 // don't verify) is a server-side data problem, not a 500 for the user: treat
624 // it as a non-match so login fails, and log it for ops. Returning an
625 // Internal error here would also be a (third-order) account oracle, it
626 // distinguishes "valid account, bad stored hash" from "valid account, wrong
627 // password" by status code (SEC minor, Run #23).
628 let reject = |what: &str, e: &dyn std::fmt::Display| {
629 tracing::error!(event = "password_hash_unverifiable", reason = %what, error = %e,
630 "stored password hash could not be parsed; treating as non-match");
631 Ok(false)
632 };
633
634 let parsed_hash = match PasswordHash::new(hash) {
635 Ok(h) => h,
636 Err(e) => return reject("parse", &e),
637 };
638 let algorithm = match Algorithm::try_from(parsed_hash.algorithm) {
639 Ok(a) => a,
640 Err(e) => return reject("algorithm", &e),
641 };
642 let version = match parsed_hash.version.map(Version::try_from).transpose() {
643 Ok(v) => v.unwrap_or(Version::V0x13),
644 Err(e) => return reject("version", &e),
645 };
646 let params = match Params::try_from(&parsed_hash) {
647 Ok(p) => p,
648 Err(e) => return reject("params", &e),
649 };
650
651 Ok(Argon2::new(algorithm, version, params)
652 .verify_password(password.as_bytes(), &parsed_hash)
653 .is_ok())
654 }
655
656 /// Async wrapper for [`hash_password`]: runs the CPU-bound Argon2id work on the
657 /// blocking thread pool so it never occupies a Tokio worker. This is the form
658 /// handlers must use.
659 pub async fn hash_password_async(password: String) -> Result<String, AppError> {
660 tokio::task::spawn_blocking(move || hash_password(&password))
661 .await
662 .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 hash task join: {e}")))?
663 }
664
665 /// Async wrapper for [`verify_password`]: runs the CPU-bound Argon2id verify on
666 /// the blocking thread pool so concurrent logins can't starve the async runtime.
667 /// This is the form handlers (including the timing-equalizer dummy verifies)
668 /// must use.
669 pub async fn verify_password_async(password: String, hash: String) -> Result<bool, AppError> {
670 tokio::task::spawn_blocking(move || verify_password(&password, &hash))
671 .await
672 .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 verify task join: {e}")))?
673 }
674
675 /// Outcome of [`relying_party_login_gate`].
676 pub enum LoginGate {
677 /// Password correct and the account may complete a relying-party login.
678 Allow,
679 /// Login refused. `just_locked` is true when *this* attempt tripped the
680 /// lockout (so an interactive caller can show a one-time lockout notice);
681 /// every other refusal reason is indistinguishable by design.
682 Deny { just_locked: bool },
683 }
684
685 /// Uniform password + account-status gate for relying-party logins (OAuth
686 /// authorize, SyncKit auth), the flows that reject 2FA accounts outright.
687 ///
688 /// Runs Argon2 first, then folds *every* refusal reason (wrong password,
689 /// suspended, deactivated, locked, 2FA-enabled) into a single accounted
690 /// decision: a denial always increments the failed-login counter, a success
691 /// always resets it. Collapsing the blocked-account cases into the wrong-password
692 /// path is what stops the counter from becoming a confirmed-password oracle, a
693 /// correct guess against a 2FA/suspended account must be indistinguishable from a
694 /// wrong one (ultra-fuzz Run 3 / Run 11 Sec M1). Both relying parties call this
695 /// instead of open-coding the ordering, so the invariant lives in one place.
696 pub async fn relying_party_login_gate(
697 pool: &sqlx::PgPool,
698 user: &db::DbUser,
699 password: &str,
700 ) -> Result<LoginGate, AppError> {
701 let valid = verify_password_async(password.to_string(), user.password_hash.clone()).await?;
702
703 let locked = user
704 .locked_until
705 .is_some_and(|locked_until| locked_until > chrono::Utc::now());
706 let denied =
707 !valid || user.is_suspended() || user.is_deactivated() || locked || user.totp_enabled;
708
709 if denied {
710 let result = db::auth::increment_failed_login(
711 pool,
712 user.id,
713 constants::MAX_LOGIN_ATTEMPTS,
714 constants::LOCKOUT_MINUTES,
715 )
716 .await?;
717 return Ok(LoginGate::Deny {
718 just_locked: result.just_locked,
719 });
720 }
721
722 db::auth::reset_failed_login(pool, user.id).await?;
723 Ok(LoginGate::Allow)
724 }
725
726 /// Store user in session with session regeneration to prevent fixation attacks
727 #[tracing::instrument(skip_all, fields(user_id = %user.id))]
728 pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> {
729 // Regenerate session ID to prevent session fixation attacks
730 // This creates a new session ID while preserving session data
731 session.cycle_id().await.context("session cycle")?;
732
733 // Regenerate CSRF token so pre-auth tokens can't be used post-auth
734 let new_csrf = crate::csrf::generate_token();
735 session
736 .insert(crate::csrf::CSRF_SESSION_KEY, &new_csrf)
737 .await
738 .context("csrf token insert")?;
739
740 session
741 .insert(USER_SESSION_KEY, user)
742 .await
743 .context("session insert")?;
744 Ok(())
745 }
746
747 /// Destroy entire session on logout to prevent session reuse
748 #[tracing::instrument(skip_all)]
749 pub async fn logout_user(session: &Session) -> Result<(), AppError> {
750 // Flush the entire session to destroy all data and invalidate session ID
751 session.flush().await.context("session flush")?;
752 Ok(())
753 }
754
755 /// Record a new session in `user_sessions` and store the tracking ID in session data.
756 /// Call this after `login_user()` in every login path.
757 #[tracing::instrument(skip_all, fields(user_id = %user_id))]
758 pub async fn track_session(
759 session: &Session,
760 pool: &PgPool,
761 user_id: UserId,
762 headers: &HeaderMap,
763 ) -> Result<(), AppError> {
764 let user_agent = headers
765 .get("user-agent")
766 .and_then(|v| v.to_str().ok())
767 .map(|s| {
768 s.chars()
769 .take(constants::USER_AGENT_MAX_LENGTH)
770 .collect::<String>()
771 });
772
773 let ip = crate::helpers::extract_client_ip(headers);
774
775 let tracking_id =
776 db::sessions::create_user_session(pool, user_id, user_agent.as_deref(), ip.as_deref())
777 .await?;
778
779 // Cap the user's active sessions so repeated logins can't grow the table
780 // unboundedly. Best-effort: a prune failure must not break the login (the row
781 // we just created is the newest and is never the one evicted).
782 match db::sessions::prune_user_sessions_over_cap(
783 pool,
784 user_id,
785 constants::MAX_SESSIONS_PER_USER,
786 )
787 .await
788 {
789 Ok(pruned) if pruned > 0 => {
790 tracing::info!(pruned, "pruned oldest sessions over the per-user cap");
791 }
792 Ok(_) => {}
793 Err(e) => tracing::warn!(error = ?e, "failed to prune sessions over the per-user cap"),
794 }
795
796 session
797 .insert(SESSION_TRACKING_KEY, tracking_id)
798 .await
799 .context("session insert")?;
800
801 Ok(())
802 }
803
804 /// Send a new-device login notification if the user has other active sessions.
805 ///
806 /// Fire-and-forget, spawns a background task. Only sends if the user has opted in
807 /// and has more than one active session (meaning this is a new device).
808 #[allow(clippy::too_many_arguments)]
809 pub async fn maybe_send_login_notification(
810 db: &sqlx::PgPool,
811 mailer: &crate::email::EmailClient,
812 bg: &crate::background::BackgroundTx,
813 config: &crate::config::Config,
814 user_id: UserId,
815 email: &str,
816 display_name: Option<&str>,
817 enabled: bool,
818 headers: &HeaderMap,
819 ) {
820 if !enabled {
821 return;
822 }
823 let session_count = match db::sessions::count_user_sessions(db, user_id).await {
824 Ok(n) => n,
825 Err(e) => {
826 tracing::warn!("Failed to count sessions for login notification: {e}");
827 return;
828 }
829 };
830 if session_count <= 1 {
831 return;
832 }
833 let user_agent = headers
834 .get("user-agent")
835 .and_then(|v| v.to_str().ok())
836 .map(|s| {
837 s.chars()
838 .take(constants::USER_AGENT_MAX_LENGTH)
839 .collect::<String>()
840 });
841 let ip = crate::helpers::extract_client_ip(headers);
842 let unsub_url = crate::email::generate_unsubscribe_url(
843 &config.host_url,
844 user_id,
845 crate::email::UnsubscribeAction::Login,
846 &user_id.to_string(),
847 &config.signing_secret,
848 );
849 let email = email.to_string();
850 let display_name = display_name.map(String::from);
851 // Inlined `spawn_email!`: the macro reads `.email`/`.bg` off AppState; this
852 // helper now holds the EmailClient + BackgroundTx slices directly.
853 let email_client = mailer.clone();
854 bg.spawn("login notification", async move {
855 if let Err(e) = email_client
856 .send_new_login_notification(
857 &email,
858 display_name.as_deref(),
859 user_agent.as_deref(),
860 ip.as_deref(),
861 Some(&unsub_url),
862 )
863 .await
864 {
865 tracing::error!(error = ?e, "failed to send login notification");
866 }
867 });
868 }
869
870 /// Check if a password appears in the HaveIBeenPwned breached passwords database.
871 /// Uses k-anonymity: only the first 5 characters of the SHA-1 hash are sent.
872 /// Returns Some(count) if breached, None if clean or API unavailable.
873 ///
874 /// This check is advisory (it never blocks a password change), so a lookup
875 /// failure fails open, but it must not fail *silently*. A network blip or
876 /// HIBP outage that disables breach checking is logged at WARN so the gap is
877 /// visible in observability rather than disappearing into a bare `?`.
878 pub async fn check_password_breach(password: &str) -> Option<u64> {
879 use sha1::{Digest, Sha1};
880
881 let hash = hex::encode(Sha1::digest(password.as_bytes())).to_uppercase();
882 let (prefix, suffix) = hash.split_at(5);
883
884 let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
885 let response = match crate::helpers::HTTP_CLIENT
886 .get(&url)
887 .header("User-Agent", "MakeNotWork-Security-Check")
888 .header("Add-Padding", "true")
889 .timeout(std::time::Duration::from_secs(3))
890 .send()
891 .await
892 {
893 Ok(resp) => resp,
894 Err(e) => {
895 tracing::warn!(error = %e, "HIBP breach lookup failed (network/timeout); breach check skipped (fail-open)");
896 return None;
897 }
898 };
899 let response = match response.text().await {
900 Ok(body) => body,
901 Err(e) => {
902 tracing::warn!(error = %e, "HIBP breach lookup: could not read response body; breach check skipped (fail-open)");
903 return None;
904 }
905 };
906
907 for line in response.lines() {
908 let mut parts = line.splitn(2, ':');
909 if let (Some(hash_suffix), Some(count)) = (parts.next(), parts.next())
910 && hash_suffix.trim() == suffix
911 {
912 return count.trim().parse().ok();
913 }
914 }
915
916 None
917 }
918
919 /// Check if a user is the admin. Returns NotFound to hide admin routes from non-admins.
920 pub fn require_admin(user: &SessionUser, config: &Config) -> Result<(), AppError> {
921 match config.admin_user_id {
922 Some(admin_id) if admin_id == user.id => Ok(()),
923 _ => Err(AppError::NotFound),
924 }
925 }
926
927 #[cfg(test)]
928 mod tests {
929 use super::*;
930 use crate::config::{BuildConfig, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig};
931
932 #[test]
933 fn hash_password_produces_valid_hash() {
934 let hash = hash_password("test_password_123").unwrap();
935 // Argon2 hashes start with $argon2
936 assert!(hash.starts_with("$argon2"));
937 }
938
939 #[test]
940 fn verify_password_correct() {
941 let hash = hash_password("correct_horse").unwrap();
942 assert!(verify_password("correct_horse", &hash).unwrap());
943 }
944
945 #[test]
946 fn verify_password_wrong() {
947 let hash = hash_password("correct_horse").unwrap();
948 assert!(!verify_password("wrong_horse", &hash).unwrap());
949 }
950
951 #[test]
952 fn verify_password_unparseable_hash_is_non_match_not_error() {
953 // A corrupt / non-Argon2 stored hash must fail login cleanly (Ok(false)),
954 // not 500, avoids an availability bug and an account oracle (SEC, Run #23).
955 for bad in [
956 "",
957 "not-a-phc-string",
958 "$argon2id$garbage",
959 "$2y$10$abcdefghijklmnopqrstuv",
960 ] {
961 assert!(
962 !verify_password("any", bad).unwrap(),
963 "hash {bad:?} should be a non-match"
964 );
965 }
966 }
967
968 #[test]
969 fn hash_password_different_each_time() {
970 let h1 = hash_password("same_password").unwrap();
971 let h2 = hash_password("same_password").unwrap();
972 // Different salts should produce different hashes
973 assert_ne!(h1, h2);
974 }
975
976 #[test]
977 fn require_admin_with_admin_id() {
978 let user = SessionUser {
979 id: "00000000-0000-0000-0000-000000000001"
980 .parse::<UserId>()
981 .unwrap(),
982 username: Username::from_trusted("admin".to_string()),
983 email: "admin@example.com".to_string(),
984 display_name: None,
985 can_create_projects: true,
986 suspended: false,
987 is_admin: true,
988 is_fan_plus: false,
989 creator_tier: None,
990 deactivated: false,
991 is_sandbox: false,
992 };
993 let config = Config {
994 host: "127.0.0.1".parse().unwrap(),
995 port: 3000,
996 database_url: "postgres://test".to_string(),
997 host_url: std::sync::Arc::from("http://localhost:3000"),
998 signing_secret: "secret".to_string(),
999 storage: None,
1000 synckit_storage: None,
1001 public_storage: None,
1002 stripe: None,
1003 admin_user_id: Some(user.id),
1004 synckit_jwt_secret: None,
1005 scan: None,
1006 cdn_base_url: "https://cdn.localhost".to_string(),
1007 user_pages_host: std::sync::Arc::from("u.localhost"),
1008 access_gate: crate::config::AccessGate::Open,
1009 sso: None,
1010 build: BuildConfig {
1011 trigger_token: None,
1012 host_linux: None,
1013 host_darwin: None,
1014 git_repos_path: None,
1015 git_ssh_host: None,
1016 },
1017 email_webhooks: EmailWebhookConfig {
1018 webhook_token: None,
1019 broadcast_webhook_token: None,
1020 inbound_webhook_token: None,
1021 enforce_sender_auth: true,
1022 },
1023 creator_pricing: CreatorTierPricing {
1024 fan_plus_price_id: None,
1025 tier_prices: std::collections::HashMap::new(),
1026 tier_annual_prices: std::collections::HashMap::new(),
1027 tier_founder_prices: std::collections::HashMap::new(),
1028 tier_founder_annual_prices: std::collections::HashMap::new(),
1029 founder_window_open: false,
1030 },
1031 integrations: IntegrationsConfig {
1032 mt_base_url: None,
1033 wam_url: None,
1034 internal_shared_secret: None,
1035 cli_service_token: None,
1036 alerts_ingest_token: None,
1037 },
1038 };
1039 assert!(require_admin(&user, &config).is_ok());
1040 }
1041
1042 #[tokio::test]
1043 #[ignore = "requires network access, run manually"]
1044 async fn check_password_breach_known_breached() {
1045 let result = check_password_breach("password").await;
1046 assert!(result.is_some());
1047 assert!(result.unwrap() > 0);
1048 }
1049
1050 #[tokio::test]
1051 #[ignore = "requires network access, run manually"]
1052 async fn check_password_breach_unknown() {
1053 // A random 64-char string should not appear in any breach database
1054 let random_pw = "xK9m2Qp7vL4nR8wJ3sY6dF1gH5bT0cU9eA2iO7lN4mP8qW3rX6zV1yB5jD0fG";
1055 let result = check_password_breach(random_pw).await;
1056 assert!(result.is_none());
1057 }
1058
1059 #[test]
1060 fn require_admin_without_admin_id() {
1061 let user = SessionUser {
1062 id: UserId::new(),
1063 username: Username::from_trusted("notadmin".to_string()),
1064 email: "user@example.com".to_string(),
1065 display_name: None,
1066 can_create_projects: false,
1067 suspended: false,
1068 is_admin: false,
1069 is_fan_plus: false,
1070 creator_tier: None,
1071 deactivated: false,
1072 is_sandbox: false,
1073 };
1074 let config = Config {
1075 host: "127.0.0.1".parse().unwrap(),
1076 port: 3000,
1077 database_url: "postgres://test".to_string(),
1078 host_url: std::sync::Arc::from("http://localhost:3000"),
1079 signing_secret: "secret".to_string(),
1080 storage: None,
1081 synckit_storage: None,
1082 public_storage: None,
1083 stripe: None,
1084 admin_user_id: None,
1085 synckit_jwt_secret: None,
1086 scan: None,
1087 cdn_base_url: "https://cdn.localhost".to_string(),
1088 user_pages_host: std::sync::Arc::from("u.localhost"),
1089 access_gate: crate::config::AccessGate::Open,
1090 sso: None,
1091 build: BuildConfig {
1092 trigger_token: None,
1093 host_linux: None,
1094 host_darwin: None,
1095 git_repos_path: None,
1096 git_ssh_host: None,
1097 },
1098 email_webhooks: EmailWebhookConfig {
1099 webhook_token: None,
1100 broadcast_webhook_token: None,
1101 inbound_webhook_token: None,
1102 enforce_sender_auth: true,
1103 },
1104 creator_pricing: CreatorTierPricing {
1105 fan_plus_price_id: None,
1106 tier_prices: std::collections::HashMap::new(),
1107 tier_annual_prices: std::collections::HashMap::new(),
1108 tier_founder_prices: std::collections::HashMap::new(),
1109 tier_founder_annual_prices: std::collections::HashMap::new(),
1110 founder_window_open: false,
1111 },
1112 integrations: IntegrationsConfig {
1113 mt_base_url: None,
1114 wam_url: None,
1115 internal_shared_secret: None,
1116 cli_service_token: None,
1117 alerts_ingest_token: None,
1118 },
1119 };
1120 assert!(require_admin(&user, &config).is_err());
1121 }
1122
1123 // ── Guard function tests ──
1124
1125 fn make_user(is_sandbox: bool, suspended: bool, deactivated: bool) -> SessionUser {
1126 SessionUser {
1127 id: UserId::new(),
1128 username: Username::from_trusted("testuser".to_string()),
1129 email: "test@example.com".to_string(),
1130 display_name: None,
1131 can_create_projects: false,
1132 suspended,
1133 is_admin: false,
1134 is_fan_plus: false,
1135 creator_tier: None,
1136 deactivated,
1137 is_sandbox,
1138 }
1139 }
1140
1141 #[test]
1142 fn check_not_sandbox_allows_normal_user() {
1143 let user = make_user(false, false, false);
1144 assert!(user.check_not_sandbox().is_ok());
1145 }
1146
1147 #[test]
1148 fn check_not_sandbox_blocks_sandbox() {
1149 let user = make_user(true, false, false);
1150 assert!(user.check_not_sandbox().is_err());
1151 }
1152
1153 #[test]
1154 fn check_not_suspended_allows_normal_user() {
1155 let user = make_user(false, false, false);
1156 assert!(user.check_not_suspended().is_ok());
1157 }
1158
1159 #[test]
1160 fn check_not_suspended_blocks_suspended() {
1161 let user = make_user(false, true, false);
1162 assert!(user.check_not_suspended().is_err());
1163 }
1164
1165 #[test]
1166 fn check_not_suspended_blocks_deactivated() {
1167 let user = make_user(false, false, true);
1168 assert!(user.check_not_suspended().is_err());
1169 }
1170
1171 #[test]
1172 fn check_not_suspended_blocks_both() {
1173 let user = make_user(false, true, true);
1174 assert!(user.check_not_suspended().is_err());
1175 }
1176 }
1177