Skip to main content

headless_lms_models/
student_number_verification_tokens.rs

1use rand::RngExt;
2use rand::distr::{Alphanumeric, SampleString};
3use secrecy::ExposeSecret;
4
5use crate::prelude::*;
6
7/// Length of a linking token, which is the only proof of ownership and is bound to no account.
8const TOKEN_LENGTH: usize = 128;
9
10#[derive(Debug, Clone)]
11pub struct StudentNumberVerificationToken {
12    pub id: Uuid,
13    pub created_at: DateTime<Utc>,
14    pub updated_at: DateTime<Utc>,
15    pub deleted_at: Option<DateTime<Utc>>,
16    pub token: DbSecret,
17    pub claimed_by_user_id: Option<Uuid>,
18    pub student_number: String,
19    pub sisu_person_id: String,
20    pub first_names: Option<String>,
21    pub last_name: Option<String>,
22    pub emailed_to: String,
23    pub course_id: Option<Uuid>,
24    pub expires_at: DateTime<Utc>,
25    pub used_at: Option<DateTime<Utc>>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct NewStudentNumberVerificationToken {
30    pub student_number: String,
31    pub sisu_person_id: String,
32    pub first_names: Option<String>,
33    pub last_name: Option<String>,
34    pub emailed_to: String,
35    pub course_id: Option<Uuid>,
36}
37
38pub fn is_valid(token: &StudentNumberVerificationToken) -> bool {
39    let now = Utc::now();
40    token.expires_at > now && token.used_at.is_none() && token.deleted_at.is_none()
41}
42
43/// Probabilistic cleanup instead of a cron.
44///
45/// Soft-delete, not DELETE: `credit_registration_account_linking_emails` references these rows, and
46/// this runs on the click path, so a foreign key violation here would 500 a student opening a valid
47/// link.
48pub async fn maybe_cleanup_expired(conn: &mut PgConnection) -> ModelResult<()> {
49    let random_num = rand::rng().random_range(1..=10);
50    if random_num == 1 {
51        info!("Cleaning up expired student number verification tokens");
52        sqlx::query!(
53            r#"
54UPDATE student_number_verification_tokens
55SET deleted_at = now()
56WHERE expires_at < now()
57  AND used_at IS NULL
58  AND deleted_at IS NULL
59            "#,
60        )
61        .execute(conn)
62        .await?;
63    }
64    Ok(())
65}
66
67/// Mints a token for a Sisu person, bound to no account: the click while logged in creates the
68/// binding. Returns the row id and the plaintext token for the mailed link.
69pub async fn insert(
70    conn: &mut PgConnection,
71    pkey_policy: PKeyPolicy<Uuid>,
72    new: &NewStudentNumberVerificationToken,
73) -> ModelResult<(Uuid, DbSecret)> {
74    let token = DbSecret::new(Alphanumeric.sample_string(&mut rand::rng(), TOKEN_LENGTH));
75    let res = sqlx::query!(
76        r#"
77INSERT INTO student_number_verification_tokens (
78    id,
79    token,
80    student_number,
81    sisu_person_id,
82    first_names,
83    last_name,
84    emailed_to,
85    course_id
86  )
87VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
88RETURNING id
89        "#,
90        pkey_policy.into_uuid(),
91        token.expose_secret(),
92        new.student_number,
93        new.sisu_person_id,
94        new.first_names,
95        new.last_name,
96        new.emailed_to,
97        new.course_id,
98    )
99    .fetch_one(conn)
100    .await?;
101    Ok((res.id, token))
102}
103
104/// A token row with everything pinned, for the seed only.
105#[derive(Debug, Clone, PartialEq)]
106pub struct SeedStudentNumberVerificationToken {
107    /// Fixed plaintext so a spec can navigate straight to the link. At least 128 characters, or the
108    /// `student_number_verification_token_length` check rejects the row.
109    pub token: String,
110    pub student_number: String,
111    pub sisu_person_id: String,
112    pub first_names: Option<String>,
113    pub last_name: Option<String>,
114    pub emailed_to: String,
115    pub course_id: Option<Uuid>,
116    pub expires_at: DateTime<Utc>,
117    pub used_at: Option<DateTime<Utc>>,
118    pub claimed_by_user_id: Option<Uuid>,
119}
120
121/// Seeds a token with a fixed plaintext value and a chosen expiry/claim state, which [`insert`]
122/// cannot do: system tests need the valid, expired and used links to be constants. Seed use only.
123pub async fn insert_seed_row(
124    conn: &mut PgConnection,
125    pkey_policy: PKeyPolicy<Uuid>,
126    seed: &SeedStudentNumberVerificationToken,
127) -> ModelResult<Uuid> {
128    let res = sqlx::query!(
129        r#"
130INSERT INTO student_number_verification_tokens (
131    id,
132    token,
133    student_number,
134    sisu_person_id,
135    first_names,
136    last_name,
137    emailed_to,
138    course_id,
139    expires_at,
140    used_at,
141    claimed_by_user_id
142  )
143VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
144RETURNING id
145        "#,
146        pkey_policy.into_uuid(),
147        seed.token,
148        seed.student_number,
149        seed.sisu_person_id,
150        seed.first_names,
151        seed.last_name,
152        seed.emailed_to,
153        seed.course_id,
154        seed.expires_at,
155        seed.used_at,
156        seed.claimed_by_user_id,
157    )
158    .fetch_one(conn)
159    .await?;
160    Ok(res.id)
161}
162
163/// Looks up an unused, unexpired token without claiming it; claiming is a separate explicit `POST`.
164pub async fn get_unclaimed_by_token(
165    conn: &mut PgConnection,
166    token: &DbSecret,
167) -> ModelResult<Option<StudentNumberVerificationToken>> {
168    maybe_cleanup_expired(conn).await?;
169
170    let res = sqlx::query_as!(
171        StudentNumberVerificationToken,
172        r#"
173SELECT *
174FROM student_number_verification_tokens
175WHERE token = $1
176  AND expires_at > now()
177  AND used_at IS NULL
178  AND deleted_at IS NULL
179        "#,
180        token.expose_secret()
181    )
182    .fetch_optional(conn)
183    .await?;
184    Ok(res)
185}
186
187pub async fn get_by_id(
188    conn: &mut PgConnection,
189    id: Uuid,
190) -> ModelResult<StudentNumberVerificationToken> {
191    let res = sqlx::query_as!(
192        StudentNumberVerificationToken,
193        r#"
194SELECT *
195FROM student_number_verification_tokens
196WHERE id = $1
197  AND deleted_at IS NULL
198        "#,
199        id
200    )
201    .fetch_one(conn)
202    .await?;
203    Ok(res)
204}
205
206/// Marks the token claimed by the account. Returns false if another claim already won the race.
207pub async fn claim(
208    conn: &mut PgConnection,
209    token: &DbSecret,
210    claimed_by_user_id: Uuid,
211) -> ModelResult<bool> {
212    let claimed = sqlx::query!(
213        r#"
214UPDATE student_number_verification_tokens
215SET used_at = now(),
216  claimed_by_user_id = $2
217WHERE token = $1
218  AND used_at IS NULL
219  AND deleted_at IS NULL
220  AND expires_at > now()
221RETURNING id
222        "#,
223        token.expose_secret(),
224        claimed_by_user_id,
225    )
226    .fetch_optional(conn)
227    .await?;
228    Ok(claimed.is_some())
229}
230
231/// Retires outstanding tokens for a student number, once the link was established some other way.
232pub async fn soft_delete_unused_for_student_number(
233    conn: &mut PgConnection,
234    student_number: &str,
235) -> ModelResult<u64> {
236    let res = sqlx::query!(
237        r#"
238UPDATE student_number_verification_tokens
239SET deleted_at = now()
240WHERE student_number = $1
241  AND used_at IS NULL
242  AND deleted_at IS NULL
243        "#,
244        student_number
245    )
246    .execute(conn)
247    .await?;
248    Ok(res.rows_affected())
249}