Skip to main content

headless_lms_server/controllers/main_frontend/
email_verification.rs

1/*!
2Handlers for HTTP requests to `/api/v0/main-frontend/email-verification`.
3
4Proving that an account can read the address it claims: `user_details.email` is self-service editable,
5and the OIDC discovery document advertises an `email_verified` claim.
6*/
7
8use headless_lms_models::{
9    user_details::{self, EmailVerificationMethod},
10    user_email_codes::{self, UserEmailCodePurpose},
11};
12use secrecy::ExposeSecret;
13use utoipa::{OpenApi, ToSchema};
14
15use crate::domain::{
16    email_ownership_verification::{
17        MAX_CODE_ATTEMPTS, VerificationEmailOutcome, queue_verification_email,
18    },
19    rate_limit_middleware_builder::{RateLimit, RateLimitConfig},
20};
21use crate::prelude::*;
22
23const PURPOSE: UserEmailCodePurpose = UserEmailCodePurpose::EmailOwnershipVerification;
24
25#[derive(OpenApi)]
26#[openapi(paths(
27    get_my_email_verification_status,
28    request_email_verification_code,
29    verify_email_ownership,
30    get_email_verification_code_for_test_mode
31))]
32pub(crate) struct MainFrontendEmailVerificationApiDoc;
33
34/// What we last mailed about the address the account holds now. Never a delivery confirmation: we
35/// hand messages to an SMTP relay and cannot see an inbox.
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
37pub struct EmailVerificationEmailInfo {
38    pub sent_at: DateTime<Utc>,
39    pub expires_at: DateTime<Utc>,
40}
41
42#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
43pub struct EmailVerificationStatus {
44    /// False switches the feature off entirely; the request and verify endpoints 404 then.
45    pub verification_enabled: bool,
46    pub email: String,
47    pub email_verified_at: Option<DateTime<Utc>>,
48    pub email_verified_method: Option<EmailVerificationMethod>,
49    pub latest_verification_email: Option<EmailVerificationEmailInfo>,
50}
51
52#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum RequestEmailVerificationOutcome {
55    Queued,
56    AlreadyVerified,
57    /// A code went to this address moments ago.
58    RecentlySent,
59}
60
61#[derive(Debug, Deserialize, ToSchema)]
62pub struct RequestEmailVerificationPayload {
63    /// Which language to mail. Falls back to English when no template exists for it.
64    pub language: String,
65}
66
67#[derive(Debug, Deserialize, ToSchema)]
68pub struct VerifyEmailOwnershipPayload {
69    #[schema(value_type = String)]
70    pub code: DbSecret,
71}
72
73/// Outcome of submitting a code. Wrong, expired, superseded and spent are one value: they are
74/// indistinguishable to someone typing digits, and telling them apart only helps a guesser.
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
76#[serde(rename_all = "snake_case")]
77pub enum VerifyEmailOwnershipResult {
78    Verified,
79    AlreadyVerified,
80    Invalid,
81}
82
83/**
84GET `/api/v0/main-frontend/email-verification/status` - Whether the signed-in account's address is
85proven, and what we last mailed about it.
86*/
87#[instrument(skip(pool, app_conf))]
88#[utoipa::path(
89    get,
90    path = "/status",
91    operation_id = "getMyEmailVerificationStatus",
92    tag = "email-verification",
93    responses(
94        (status = 200, description = "Email verification status of the signed-in user", body = EmailVerificationStatus)
95    )
96)]
97pub async fn get_my_email_verification_status(
98    user: AuthUser,
99    pool: web::Data<PgPool>,
100    app_conf: web::Data<ApplicationConfiguration>,
101) -> ControllerResult<web::Json<EmailVerificationStatus>> {
102    let mut conn = pool.acquire().await?;
103    let token = skip_authorize();
104
105    let details = user_details::get_user_details_by_user_id(&mut conn, user.id).await?;
106    // An address change retires the code, so a live one always belongs to the current address.
107    let live_code =
108        user_email_codes::get_unused_user_email_code_with_user_id(&mut conn, user.id, PURPOSE)
109            .await?;
110
111    token.authorized_ok(web::Json(EmailVerificationStatus {
112        verification_enabled: app_conf.enable_email_ownership_verification,
113        email: details.email,
114        email_verified_at: details.email_verified_at,
115        email_verified_method: details.email_verified_method,
116        latest_verification_email: live_code.map(|code| EmailVerificationEmailInfo {
117            sent_at: code.created_at,
118            expires_at: code.expires_at,
119        }),
120    }))
121}
122
123/**
124POST `/api/v0/main-frontend/email-verification/request` - Mails a fresh verification code to the
125signed-in account's current address.
126*/
127#[instrument(skip(pool, payload, app_conf))]
128#[utoipa::path(
129    post,
130    path = "/request",
131    operation_id = "requestEmailVerificationCode",
132    tag = "email-verification",
133    request_body = RequestEmailVerificationPayload,
134    responses(
135        (status = 200, description = "What the request did", body = RequestEmailVerificationOutcome),
136        (status = 404, description = "Email ownership verification is switched off")
137    )
138)]
139pub async fn request_email_verification_code(
140    user: AuthUser,
141    pool: web::Data<PgPool>,
142    payload: web::Json<RequestEmailVerificationPayload>,
143    app_conf: web::Data<ApplicationConfiguration>,
144) -> ControllerResult<web::Json<RequestEmailVerificationOutcome>> {
145    let mut conn = pool.acquire().await?;
146    let token = skip_authorize();
147
148    if !app_conf.enable_email_ownership_verification {
149        return Err(controller_err!(NotFound, "Not found.".to_string()));
150    }
151
152    let outcome = queue_verification_email(&mut conn, user.id, &payload.language).await?;
153
154    token.authorized_ok(web::Json(match outcome {
155        VerificationEmailOutcome::Queued => RequestEmailVerificationOutcome::Queued,
156        VerificationEmailOutcome::AlreadyVerified => {
157            RequestEmailVerificationOutcome::AlreadyVerified
158        }
159        VerificationEmailOutcome::RecentlySent => RequestEmailVerificationOutcome::RecentlySent,
160    }))
161}
162
163/**
164POST `/api/v0/main-frontend/email-verification/verify` - Spends a mailed code and records the proof.
165
166Authenticated and scoped to the caller's own account, so the code is the only secret involved and it
167never has to identify anybody on its own.
168*/
169#[instrument(skip(pool, payload, app_conf))]
170#[utoipa::path(
171    post,
172    path = "/verify",
173    operation_id = "verifyEmailOwnership",
174    tag = "email-verification",
175    request_body = VerifyEmailOwnershipPayload,
176    responses(
177        (status = 200, description = "Outcome of submitting the code", body = VerifyEmailOwnershipResult),
178        (status = 404, description = "Email ownership verification is switched off")
179    )
180)]
181pub async fn verify_email_ownership(
182    user: AuthUser,
183    pool: web::Data<PgPool>,
184    payload: web::Json<VerifyEmailOwnershipPayload>,
185    app_conf: web::Data<ApplicationConfiguration>,
186) -> ControllerResult<web::Json<VerifyEmailOwnershipResult>> {
187    let mut conn = pool.acquire().await?;
188    let token = skip_authorize();
189
190    if !app_conf.enable_email_ownership_verification {
191        return Err(controller_err!(NotFound, "Not found.".to_string()));
192    }
193
194    // One transaction: spending the code without recording the proof would leave the account
195    // unverified with nothing left to type.
196    let mut tx = conn.begin().await?;
197
198    let result = if user_details::get_email_verification(&mut tx, user.id)
199        .await?
200        .is_some()
201    {
202        VerifyEmailOwnershipResult::AlreadyVerified
203    } else if !user_email_codes::is_reset_user_email_code_valid(
204        &mut tx,
205        user.id,
206        PURPOSE,
207        &payload.code,
208    )
209    .await?
210    {
211        user_email_codes::record_failed_attempt(&mut tx, user.id, PURPOSE, MAX_CODE_ATTEMPTS)
212            .await?;
213        VerifyEmailOwnershipResult::Invalid
214    } else if user_email_codes::mark_user_email_code_used(&mut tx, user.id, PURPOSE, &payload.code)
215        .await?
216    {
217        user_details::set_email_verified(
218            &mut tx,
219            user.id,
220            EmailVerificationMethod::EmailedCode,
221            Utc::now(),
222        )
223        .await?;
224        VerifyEmailOwnershipResult::Verified
225    } else {
226        // The spend blocks on the winner's row lock and then matches nothing, so a concurrent
227        // duplicate submission lands here rather than recording a second proof.
228        VerifyEmailOwnershipResult::Invalid
229    };
230
231    tx.commit().await?;
232
233    token.authorized_ok(web::Json(result))
234}
235
236/**
237GET `/api/v0/main-frontend/email-verification/test-mode-code` - The signed-in account's own pending
238verification code.
239
240Exists because the system tests have no mail capture. 404 unless `TEST_MODE` is on, and scoped to the
241caller's own account, so an accidentally open gate only ever hands you your own code.
242*/
243#[instrument(skip(pool, app_conf))]
244#[utoipa::path(
245    get,
246    path = "/test-mode-code",
247    operation_id = "getEmailVerificationCodeForTestMode",
248    tag = "email-verification",
249    responses(
250        (status = 200, description = "The caller's pending verification code", body = String),
251        (status = 404, description = "Not in test mode, or no pending code")
252    )
253)]
254pub async fn get_email_verification_code_for_test_mode(
255    user: AuthUser,
256    pool: web::Data<PgPool>,
257    app_conf: web::Data<ApplicationConfiguration>,
258) -> ControllerResult<web::Json<String>> {
259    let mut conn = pool.acquire().await?;
260    let token = skip_authorize();
261
262    if !app_conf.test_mode {
263        return Err(controller_err!(NotFound, "Not found.".to_string()));
264    }
265
266    let live_code =
267        user_email_codes::get_unused_user_email_code_with_user_id(&mut conn, user.id, PURPOSE)
268            .await?
269            .ok_or_else(|| {
270                controller_err!(NotFound, "No pending email verification code.".to_string())
271            })?;
272
273    token.authorized_ok(web::Json(live_code.code.expose_secret().to_string()))
274}
275
276pub fn _add_routes(cfg: &mut ServiceConfig) {
277    cfg.route("/status", web::get().to(get_my_email_verification_status))
278        .service(
279            web::resource("/request")
280                .wrap(RateLimit::new(RateLimitConfig {
281                    per_minute: None,
282                    per_hour: Some(10),
283                    per_day: Some(30),
284                    per_month: None,
285                    ..Default::default()
286                }))
287                .to(request_email_verification_code),
288        )
289        .service(
290            web::resource("/verify")
291                .wrap(RateLimit::new(RateLimitConfig {
292                    per_minute: Some(10),
293                    per_hour: Some(50),
294                    per_day: None,
295                    per_month: None,
296                    ..Default::default()
297                }))
298                .to(verify_email_ownership),
299        )
300        .route(
301            "/test-mode-code",
302            web::get().to(get_email_verification_code_for_test_mode),
303        );
304}