headless_lms_server/controllers/main_frontend/
email_verification.rs1use 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#[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 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 RecentlySent,
59}
60
61#[derive(Debug, Deserialize, ToSchema)]
62pub struct RequestEmailVerificationPayload {
63 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#[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#[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 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#[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#[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 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 VerifyEmailOwnershipResult::Invalid
229 };
230
231 tx.commit().await?;
232
233 token.authorized_ok(web::Json(result))
234}
235
236#[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}