fabrica

hanna/fabrica

4067 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Activation and password-reset invites.
6//!
7//! The emailed token is never stored; only its SHA-256 (`token_hash`) is, and
8//! redemption looks the invite up by that hash. Invites are single-use
9//! (`used_at`) and time-limited (`expires_at`).
10
11use model::Invite;
12use sqlx::any::AnyRow;
13use sqlx::{AssertSqlSafe, Row};
14
15use crate::{Store, StoreError, new_id, now_ms};
16
17/// The columns of `invites` in a fixed order, shared by every `SELECT`.
18const INVITE_COLUMNS: &str = "id, user_id, token_hash, purpose, expires_at, used_at, created_at";
19
20/// Fields needed to mint an invite. The caller generates the token and passes its
21/// SHA-256 here; the store assigns the id and creation time.
22#[derive(Debug, Clone)]
23pub struct NewInvite {
24 /// The user the invite activates.
25 pub user_id: String,
26 /// SHA-256 (hex) of the emailed token.
27 pub token_hash: String,
28 /// `"activate"` or `"reset"`.
29 pub purpose: String,
30 /// Absolute expiry, epoch ms.
31 pub expires_at: i64,
32}
33
34impl Store {
35 /// Create an invite row.
36 ///
37 /// # Errors
38 ///
39 /// Returns [`StoreError::Query`] on a database failure.
40 pub async fn create_invite(&self, new: NewInvite) -> Result<Invite, StoreError> {
41 let invite = Invite {
42 id: new_id(),
43 user_id: new.user_id,
44 token_hash: new.token_hash,
45 purpose: new.purpose,
46 expires_at: new.expires_at,
47 used_at: None,
48 created_at: now_ms(),
49 };
50 let sql = "INSERT INTO invites (id, user_id, token_hash, purpose, expires_at, used_at, created_at) \
51 VALUES ($1, $2, $3, $4, $5, $6, $7)";
52 sqlx::query(sql)
53 .bind(invite.id.clone())
54 .bind(invite.user_id.clone())
55 .bind(invite.token_hash.clone())
56 .bind(invite.purpose.clone())
57 .bind(invite.expires_at)
58 .bind(invite.used_at)
59 .bind(invite.created_at)
60 .execute(&self.pool)
61 .await?;
62 Ok(invite)
63 }
64
65 /// Look up an invite by its token hash, regardless of state. The caller checks
66 /// `used_at`/`expires_at` so it can distinguish "already used" from "expired"
67 /// from "unknown" for the redemption page.
68 ///
69 /// # Errors
70 ///
71 /// Returns [`StoreError::Query`] on a database failure.
72 pub async fn invite_by_token_hash(
73 &self,
74 token_hash: &str,
75 ) -> Result<Option<Invite>, StoreError> {
76 let sql = format!("SELECT {INVITE_COLUMNS} FROM invites WHERE token_hash = $1");
77 let row = sqlx::query(AssertSqlSafe(sql))
78 .bind(token_hash)
79 .fetch_optional(&self.pool)
80 .await?;
81 Ok(row.as_ref().map(map_invite).transpose()?)
82 }
83
84 /// Mark an invite redeemed. Guards against a double-redeem with a
85 /// `used_at IS NULL` predicate, so `true` means "this call redeemed it".
86 ///
87 /// # Errors
88 ///
89 /// Returns [`StoreError::Query`] on a database failure.
90 pub async fn mark_invite_used(&self, invite_id: &str) -> Result<bool, StoreError> {
91 let updated =
92 sqlx::query("UPDATE invites SET used_at = $1 WHERE id = $2 AND used_at IS NULL")
93 .bind(now_ms())
94 .bind(invite_id)
95 .execute(&self.pool)
96 .await?
97 .rows_affected();
98 Ok(updated > 0)
99 }
100}
101
102/// Map an `invites` row (selected via [`INVITE_COLUMNS`]) to an [`Invite`].
103fn map_invite(row: &AnyRow) -> Result<Invite, sqlx::Error> {
104 Ok(Invite {
105 id: row.try_get("id")?,
106 user_id: row.try_get("user_id")?,
107 token_hash: row.try_get("token_hash")?,
108 purpose: row.try_get("purpose")?,
109 expires_at: row.try_get("expires_at")?,
110 used_at: row.try_get("used_at")?,
111 created_at: row.try_get("created_at")?,
112 })
113}