headless_lms_server/controllers/main_frontend/
email_verification.rs1use 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#[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 pub verification_enabled: bool,
47 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 RecentlySent,
63}
64
65#[derive(Debug, Deserialize, ToSchema)]
66pub struct RequestEmailVerificationPayload {
67 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#[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#[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 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#[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#[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 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 VerifyEmailOwnershipResult::Invalid
243 };
244
245 tx.commit().await?;
246
247 token.authorized_ok(web::Json(result))
248}
249
250#[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}