Skip to main content

max / makenotwork

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