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