user_repo.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. //! `users` repository (email-as-username, Argon2id, encrypted TOTP).
  2. //!
  3. //! Admin-provisioned users only; no public sign-up path exists. Email is
  4. //! normalized to lowercase before every insert/lookup so the blind index and
  5. //! the failed-login counters agree on one canonical account label.
  6. //!
  7. //! Email is stored sealed (`email_enc`, AES-256-GCM under the PII key chain)
  8. //! and looked up through a keyed blind index (`email_index`, newest-first
  9. //! candidates so retained keys keep matching after a rotation). Reads use the
  10. //! dual predicate `email_index = ANY($candidates) OR (email_enc IS NULL AND
  11. //! email = $canonical)` so legacy plaintext rows (both new columns NULL)
  12. //! stay readable. Plaintext `email` is written only while `dual_write` holds.
  13. //!
  14. //! The repository never returns [`password_hash`]: [`UserRecord`] omits it and
  15. //! [`verify_password`] reads it only long enough to run the constant-time
  16. //! Argon2id comparison. Failed-login/lockout counters are Redis-only (see the
  17. //! `lockout` section); no lockout state is ever written to Postgres, which is
  18. //! what makes the counters safe under horizontal scaling.
  19. #[cfg(test)]
  20. mod tests;
  21. use std::fmt;
  22. use time::OffsetDateTime;
  23. use uuid::Uuid;
  24. use crate::{
  25. crypto::{
  26. field::{blind_index_active, blind_index_candidates, open_text, seal_text},
  27. secret::{Argon2Params, hash_secret, verify_secret},
  28. },
  29. error::AppError,
  30. keys::KeyRing,
  31. };
  32. /// A user to provision. `password_hash` is an Argon2id PHC string produced by
  33. /// [`crate::crypto::secret::hash_secret`]; the plaintext never reaches here.
  34. pub struct NewUser {
  35. /// Email address (client-supplied casing; normalized on insert).
  36. pub email: String,
  37. /// Pre-computed Argon2id PHC hash of the account password.
  38. pub password_hash: String,
  39. /// Whether the account is an administrator.
  40. pub is_admin: bool,
  41. /// Whether the account must change its password before normal use.
  42. pub must_change_password: bool,
  43. }
  44. impl fmt::Debug for NewUser {
  45. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  46. f.debug_struct("NewUser")
  47. .field("email", &self.email)
  48. .field("password_hash", &"[REDACTED]")
  49. .field("is_admin", &self.is_admin)
  50. .field("must_change_password", &self.must_change_password)
  51. .finish()
  52. }
  53. }
  54. /// A stored user, safe to hand to callers: never carries the password hash or
  55. /// the TOTP secret ciphertext.
  56. #[derive(Debug, Clone, PartialEq, Eq)]
  57. pub struct UserRecord {
  58. /// Primary key.
  59. pub id: Uuid,
  60. /// Canonical (lowercase) email.
  61. pub email: String,
  62. /// Whether the account is an administrator.
  63. pub is_admin: bool,
  64. /// Whether the account must change its password before normal use.
  65. pub must_change_password: bool,
  66. /// Whether the account is disabled.
  67. pub disabled: bool,
  68. /// Whether TOTP second-factor is enabled.
  69. pub totp_enabled: bool,
  70. /// When the password was last set, if ever.
  71. pub password_changed_at: Option<OffsetDateTime>,
  72. }
  73. /// Outcome of a password verification. Never carries the hash.
  74. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  75. pub enum PasswordVerdict {
  76. /// The password matched.
  77. Matched,
  78. /// The password did not match.
  79. Mismatched,
  80. /// No account exists for the supplied email.
  81. Unknown,
  82. /// The account exists but is disabled (authentication must be refused).
  83. Disabled,
  84. }
  85. /// Normalize an email-as-username: trim surrounding whitespace, lowercase.
  86. #[must_use]
  87. pub fn normalize_email(email: &str) -> String {
  88. email.trim().to_lowercase()
  89. }
  90. /// Provision a user, normalizing the email and stamping timestamps.
  91. ///
  92. /// The canonical email is sealed into `email_enc` and indexed in
  93. /// `email_index`; plaintext `email` is written only while `dual_write` holds.
  94. /// A dual-predicate existence check runs before INSERT because the partial
  95. /// unique index alone cannot catch a duplicate across the encrypt-only
  96. /// pre-backfill window (legacy rows carry a NULL index).
  97. ///
  98. /// `password_changed_at` is set when the account is created ready-to-use and
  99. /// left `NULL` when the account must change its password first.
  100. ///
  101. /// # Errors
  102. /// Returns [`AppError::DuplicateEmail`] on a duplicate email (including a
  103. /// race lost at INSERT), `temporarily_unavailable` when PII sealing fails,
  104. /// or a masked error on any other database failure.
  105. pub async fn create_user(
  106. pool: &sqlx::PgPool,
  107. keys: &KeyRing,
  108. dual_write: bool,
  109. new_user: &NewUser,
  110. ) -> Result<UserRecord, AppError> {
  111. let canonical = normalize_email(&new_user.email);
  112. let candidates = blind_index_candidates(keys, &canonical);
  113. let exists = sqlx::query_scalar!(
  114. r#"SELECT EXISTS(
  115. SELECT 1 FROM users
  116. WHERE email_index = ANY($1)
  117. OR (email_enc IS NULL AND email = $2::text::citext)
  118. ) AS "exists!""#,
  119. &candidates,
  120. canonical,
  121. )
  122. .fetch_one(pool)
  123. .await?;
  124. if exists {
  125. return Err(AppError::DuplicateEmail);
  126. }
  127. let index = blind_index_active(keys, &canonical);
  128. let enc = seal_text(keys, &canonical).map_err(AppError::from)?;
  129. let plain: Option<&str> = if dual_write { Some(&canonical) } else { None };
  130. let row = sqlx::query!(
  131. r#"INSERT INTO users
  132. (id, email, email_enc, email_index, password_hash, is_admin,
  133. must_change_password, password_changed_at, created_at, updated_at)
  134. VALUES ($1, $2::text::citext, $3, $4, $5, $6, $7,
  135. CASE WHEN $7 THEN NULL ELSE now() END, now(), now())
  136. RETURNING id, email::text AS email, email_enc, is_admin,
  137. must_change_password, disabled, totp_enabled, password_changed_at"#,
  138. Uuid::new_v4(),
  139. plain,
  140. enc,
  141. index,
  142. new_user.password_hash,
  143. new_user.is_admin,
  144. new_user.must_change_password,
  145. )
  146. .fetch_one(pool)
  147. .await?;
  148. Ok(UserRecord {
  149. id: row.id,
  150. email: decrypt_stored_email(keys, row.email_enc.as_deref(), row.email.as_deref())?,
  151. is_admin: row.is_admin,
  152. must_change_password: row.must_change_password,
  153. disabled: row.disabled,
  154. totp_enabled: row.totp_enabled,
  155. password_changed_at: row.password_changed_at,
  156. })
  157. }
  158. /// Find a user by email (case-insensitive); `None` when absent.
  159. ///
  160. /// Matches the blind-index candidates first and falls back to the legacy
  161. /// plaintext predicate only for rows that carry no ciphertext.
  162. ///
  163. /// # Errors
  164. /// Returns [`AppError::PiiDecrypt`] when the matched row's ciphertext fails
  165. /// to open, or a masked error on any database failure.
  166. pub async fn find_by_email(
  167. pool: &sqlx::PgPool,
  168. keys: &KeyRing,
  169. email: &str,
  170. ) -> Result<Option<UserRecord>, AppError> {
  171. let canonical = normalize_email(email);
  172. let candidates = blind_index_candidates(keys, &canonical);
  173. let row = sqlx::query!(
  174. r#"SELECT id, email::text AS email, email_enc, is_admin, must_change_password,
  175. disabled, totp_enabled, password_changed_at
  176. FROM users
  177. WHERE email_index = ANY($1)
  178. OR (email_enc IS NULL AND email = $2::text::citext)"#,
  179. &candidates,
  180. canonical,
  181. )
  182. .fetch_optional(pool)
  183. .await?;
  184. row
  185. .map(|row| {
  186. decrypt_stored_email(keys, row.email_enc.as_deref(), row.email.as_deref()).map(|email| {
  187. UserRecord {
  188. id: row.id,
  189. email,
  190. is_admin: row.is_admin,
  191. must_change_password: row.must_change_password,
  192. disabled: row.disabled,
  193. totp_enabled: row.totp_enabled,
  194. password_changed_at: row.password_changed_at,
  195. }
  196. })
  197. })
  198. .transpose()
  199. }
  200. /// Use-time account status of a token subject.
  201. ///
  202. /// Token subjects are inconsistent by origin: authorization-code and
  203. /// refresh-token records carry the session subject (the user email), while
  204. /// device-code records carry the user UUID string, and client-credentials
  205. /// records carry an empty subject. This enum lets callers treat "resolves to
  206. /// a disabled user" as fatal while leaving unmapped (synthetic or
  207. /// client-credentials) subjects live.
  208. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  209. pub enum SubjectAccountStatus {
  210. /// The subject maps to no user; the token stays live.
  211. Unmapped,
  212. /// The subject maps to an enabled user; the token stays live.
  213. Enabled,
  214. /// The subject maps to a disabled user; the token must go inactive.
  215. Disabled,
  216. }
  217. /// Resolve a token subject to its account status without decrypting PII.
  218. ///
  219. /// An empty subject (client-credentials tokens) short-circuits to
  220. /// [`SubjectAccountStatus::Unmapped`]. A subject that parses as a `Uuid` is
  221. /// looked up by primary key; an email-shaped subject is normalized and matched
  222. /// through the email blind index, mirroring [`find_by_email`]. Only the
  223. /// `disabled` flag is read, never the sealed email. A subject that matches no
  224. /// row reports [`SubjectAccountStatus::Unmapped`].
  225. ///
  226. /// # Errors
  227. /// Returns a masked error on any database failure; callers must fail closed
  228. /// (treat the token as unusable) rather than defaulting to active.
  229. pub async fn subject_account_status(
  230. pool: &sqlx::PgPool,
  231. keys: &KeyRing,
  232. subject: &str,
  233. ) -> Result<SubjectAccountStatus, AppError> {
  234. const fn map(disabled: bool) -> SubjectAccountStatus {
  235. if disabled {
  236. SubjectAccountStatus::Disabled
  237. } else {
  238. SubjectAccountStatus::Enabled
  239. }
  240. }
  241. let subject = subject.trim();
  242. if subject.is_empty() {
  243. return Ok(SubjectAccountStatus::Unmapped);
  244. }
  245. if let Ok(id) = subject.parse::<Uuid>() {
  246. let row = sqlx::query!(r#"SELECT disabled FROM users WHERE id = $1"#, id)
  247. .fetch_optional(pool)
  248. .await?;
  249. return Ok(row.map_or(SubjectAccountStatus::Unmapped, |row| map(row.disabled)));
  250. }
  251. // A user identifier is a UUID or an email; a value that is neither cannot
  252. // match a row, so skip the query (this keeps synthetic subjects off the
  253. // database hot path).
  254. if !subject.contains('@') {
  255. return Ok(SubjectAccountStatus::Unmapped);
  256. }
  257. let canonical = normalize_email(subject);
  258. let candidates = blind_index_candidates(keys, &canonical);
  259. let row = sqlx::query!(
  260. r#"SELECT disabled FROM users
  261. WHERE email_index = ANY($1)
  262. OR (email_enc IS NULL AND email = $2::text::citext)"#,
  263. &candidates,
  264. canonical,
  265. )
  266. .fetch_optional(pool)
  267. .await?;
  268. Ok(row.map_or(SubjectAccountStatus::Unmapped, |row| map(row.disabled)))
  269. }
  270. /// Resolve a token subject to its user id, or `None` when it maps to no user.
  271. ///
  272. /// Mirrors [`subject_account_status`] without decrypting PII: an empty subject
  273. /// (client-credentials tokens) and a non-email, non-UUID subject short-circuit
  274. /// to `None`; a UUID subject resolves by primary key; an email-shaped subject
  275. /// is normalized and matched through the email blind index like
  276. /// [`find_by_email`]. Callers treat `None` as "no user term".
  277. ///
  278. /// # Errors
  279. /// Returns a masked error on any database failure.
  280. pub async fn subject_user_id(
  281. pool: &sqlx::PgPool,
  282. keys: &KeyRing,
  283. subject: &str,
  284. ) -> Result<Option<Uuid>, AppError> {
  285. let subject = subject.trim();
  286. if subject.is_empty() {
  287. return Ok(None);
  288. }
  289. if let Ok(id) = subject.parse::<Uuid>() {
  290. return match subject_account_status(pool, keys, subject).await? {
  291. SubjectAccountStatus::Unmapped => Ok(None),
  292. SubjectAccountStatus::Enabled | SubjectAccountStatus::Disabled => Ok(Some(id)),
  293. };
  294. }
  295. if !subject.contains('@') {
  296. return Ok(None);
  297. }
  298. Ok(
  299. find_by_email(pool, keys, subject)
  300. .await?
  301. .map(|user| user.id),
  302. )
  303. }
  304. /// Verify a password against the stored Argon2id hash.
  305. ///
  306. /// Uses the same dual predicate as [`find_by_email`], never the legacy
  307. /// plaintext predicate alone. A tampered `email_enc` on the matched row
  308. /// fails closed with [`AppError::PiiDecrypt`] even though verification only
  309. /// needs the password hash.
  310. ///
  311. /// # Errors
  312. /// Returns [`AppError::PiiDecrypt`] on a corrupt row, or a masked error when
  313. /// the stored hash is malformed or the query fails.
  314. pub async fn verify_password(
  315. pool: &sqlx::PgPool,
  316. keys: &KeyRing,
  317. email: &str,
  318. password: &str,
  319. ) -> Result<PasswordVerdict, AppError> {
  320. let canonical = normalize_email(email);
  321. let candidates = blind_index_candidates(keys, &canonical);
  322. let row = sqlx::query!(
  323. r#"SELECT password_hash, disabled, email_enc
  324. FROM users
  325. WHERE email_index = ANY($1)
  326. OR (email_enc IS NULL AND email = $2::text::citext)"#,
  327. &candidates,
  328. canonical,
  329. )
  330. .fetch_optional(pool)
  331. .await?;
  332. let Some(row) = row else {
  333. return Ok(PasswordVerdict::Unknown);
  334. };
  335. if let Some(blob) = row.email_enc.as_deref() {
  336. open_text(keys, blob).map_err(|_| AppError::PiiDecrypt)?;
  337. }
  338. if row.disabled {
  339. return Ok(PasswordVerdict::Disabled);
  340. }
  341. match verify_secret(password, &row.password_hash).map_err(AppError::internal)? {
  342. true => Ok(PasswordVerdict::Matched),
  343. false => Ok(PasswordVerdict::Mismatched),
  344. }
  345. }
  346. /// Resolve the stored email: open the ciphertext when present, else use the
  347. /// legacy plaintext. A non-NULL ciphertext that fails to open never falls
  348. /// back to plaintext; a row with neither representation fails closed.
  349. ///
  350. /// # Errors
  351. /// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open
  352. /// or when the row carries no usable email at all.
  353. fn decrypt_stored_email(
  354. keys: &KeyRing,
  355. enc: Option<&[u8]>,
  356. plain: Option<&str>,
  357. ) -> Result<String, AppError> {
  358. if let Some(blob) = enc {
  359. return open_text(keys, blob).map_err(|_| AppError::PiiDecrypt);
  360. }
  361. plain.map(str::to_owned).ok_or(AppError::PiiDecrypt)
  362. }
  363. mod totp;
  364. pub use totp::{
  365. clear_totp_secret, consume_recovery_code, set_recovery_codes, set_totp_enabled, set_totp_secret,
  366. totp_secret_blob,
  367. };
  368. /// Disable a user account. Returns whether a row was updated.
  369. ///
  370. /// # Errors
  371. /// Returns an error on any database failure.
  372. pub async fn disable_user(pool: &sqlx::PgPool, user_id: Uuid) -> Result<bool, AppError> {
  373. let result = sqlx::query!(
  374. r#"UPDATE users SET disabled = true, updated_at = now() WHERE id = $1"#,
  375. user_id,
  376. )
  377. .execute(pool)
  378. .await?;
  379. Ok(result.rows_affected() == 1)
  380. }
  381. /// Promote (`true`) or demote (`false`) an administrator.
  382. ///
  383. /// Returns whether a user row was updated.
  384. ///
  385. /// # Errors
  386. /// Returns an error on any database failure.
  387. pub async fn set_admin(
  388. pool: &sqlx::PgPool,
  389. user_id: Uuid,
  390. is_admin: bool,
  391. ) -> Result<bool, AppError> {
  392. let result = sqlx::query!(
  393. r#"UPDATE users SET is_admin = $2, updated_at = now() WHERE id = $1"#,
  394. user_id,
  395. is_admin,
  396. )
  397. .execute(pool)
  398. .await?;
  399. Ok(result.rows_affected() == 1)
  400. }
  401. /// Whether any enabled administrator account exists (bootstrap gate).
  402. ///
  403. /// # Errors
  404. /// Returns an error on any database failure.
  405. pub async fn has_any_admin(pool: &sqlx::PgPool) -> Result<bool, AppError> {
  406. let exists = sqlx::query_scalar!(
  407. r#"SELECT EXISTS(SELECT 1 FROM users WHERE is_admin AND NOT disabled) AS "exists!""#,
  408. )
  409. .fetch_one(pool)
  410. .await?;
  411. Ok(exists)
  412. }
  413. /// Set a new password hash, clear `must_change_password`, and stamp
  414. /// `password_changed_at`. Returns whether a user row was updated.
  415. ///
  416. /// # Errors
  417. /// Returns an error on any database failure.
  418. pub async fn set_password(
  419. pool: &sqlx::PgPool,
  420. user_id: Uuid,
  421. password_hash: &str,
  422. ) -> Result<bool, AppError> {
  423. let result = sqlx::query!(
  424. r#"UPDATE users
  425. SET password_hash = $2,
  426. must_change_password = false,
  427. password_changed_at = now(),
  428. updated_at = now()
  429. WHERE id = $1"#,
  430. user_id,
  431. password_hash,
  432. )
  433. .execute(pool)
  434. .await?;
  435. Ok(result.rows_affected() == 1)
  436. }
  437. /// Hash a password with the configured Argon2id parameters.
  438. ///
  439. /// Convenience for provisioning flows so callers never hand-roll hashing.
  440. ///
  441. /// # Errors
  442. /// Returns an error if the parameters are invalid or hashing fails.
  443. pub fn hash_password(password: &str, params: &Argon2Params) -> Result<String, AppError> {
  444. hash_secret(password, params).map_err(AppError::internal)
  445. }
  446. // ---------------------------------------------------------------------------
  447. // Redis failed-login / lockout counters (used by the login flow)
  448. // ---------------------------------------------------------------------------
  449. mod lockout;
  450. pub use lockout::{
  451. LockoutPolicy, clear_login_failures, ip_failure_count, is_locked_out, is_unknown_locked_out,
  452. lockout_remaining, login_failure_count, register_login_failure, register_unknown_failure,
  453. unknown_failure_count, unlock_login,
  454. };