Skip to main content

max / makenotwork

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