1use crate::{
6 OAuthClient,
7 domain::{
8 authorization::{
9 self, ActionOnResource, authorize_with_fetched_list_of_roles, skip_authorize,
10 },
11 rate_limit_middleware_builder::{RateLimit, RateLimitConfig},
12 },
13 prelude::*,
14};
15use actix_session::Session;
16use anyhow::Error;
17use anyhow::anyhow;
18use headless_lms_models::ModelResult;
19use headless_lms_models::{
20 email_templates::EmailTemplateType, email_verification_tokens, user_email_codes,
21 user_passwords, users,
22};
23use headless_lms_utils::{
24 prelude::UtilErrorType,
25 tmc::{NewUserInfo, TmcClient},
26};
27use secrecy::SecretString;
28use tracing_log::log;
29use utoipa::{OpenApi, ToSchema};
30
31#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
32
33pub struct Login {
34 pub email: String,
35 pub password: String,
36}
37
38#[derive(Debug, Serialize, Deserialize, ToSchema)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum LoginResponse {
41 Success,
42 RequiresEmailVerification { email_verification_token: String },
43 Failed,
44}
45
46#[utoipa::path(
51 post,
52 path = "/authorize",
53 tag = "auth",
54 operation_id = "postAuthAuthorize",
55 request_body = ActionOnResource,
56 responses(
57 (status = 200, description = "Whether the action is allowed for the current user", body = bool)
58 )
59)]
60#[instrument(skip(pool, payload,))]
61pub async fn authorize_action_on_resource(
62 pool: web::Data<PgPool>,
63 user: Option<AuthUser>,
64 payload: web::Json<ActionOnResource>,
65) -> ControllerResult<web::Json<bool>> {
66 let mut conn = pool.acquire().await?;
67 let data = payload.0;
68 if let Some(user) = user {
69 match authorize(&mut conn, data.action, Some(user.id), data.resource).await {
70 Ok(true_token) => true_token.authorized_ok(web::Json(true)),
71 _ => {
72 let false_token = skip_authorize();
74 false_token.authorized_ok(web::Json(false))
75 }
76 }
77 } else {
78 let false_token = skip_authorize();
80 false_token.authorized_ok(web::Json(false))
81 }
82}
83
84#[derive(Debug, Serialize, Deserialize, ToSchema)]
85
86pub struct CreateAccountDetails {
87 pub email: String,
88 pub first_name: String,
89 pub last_name: String,
90 pub language: String,
91 pub password: String,
92 pub password_confirmation: String,
93 pub country: String,
94 pub email_communication_consent: bool,
95}
96
97#[utoipa::path(
118 post,
119 path = "/signup",
120 tag = "auth",
121 operation_id = "postAuthSignup",
122 request_body = CreateAccountDetails,
123 responses(
124 (status = 200, description = "Account created; session established"),
125 (status = 400, description = "Cannot sign up (e.g. already signed in or validation error)")
126 )
127)]
128#[instrument(skip(session, pool, payload, app_conf))]
129pub async fn signup(
130 session: Session,
131 payload: web::Json<CreateAccountDetails>,
132 pool: web::Data<PgPool>,
133 user: Option<AuthUser>,
134 app_conf: web::Data<ApplicationConfiguration>,
135 tmc_client: web::Data<TmcClient>,
136) -> ControllerResult<HttpResponse> {
137 let user_details = payload.0;
138 let mut conn = pool.acquire().await?;
139
140 if app_conf.test_mode {
141 return handle_test_mode_signup(&mut conn, &session, &user_details, &app_conf).await;
142 }
143 if user.is_none() {
144 let upstream_id = tmc_client
145 .post_new_user_to_tmc(
146 NewUserInfo {
147 first_name: user_details.first_name.clone(),
148 last_name: user_details.last_name.clone(),
149 email: user_details.email.clone(),
150 password: user_details.password.clone(),
151 password_confirmation: user_details.password_confirmation.clone(),
152 language: user_details.language.clone(),
153 },
154 app_conf.as_ref(),
155 )
156 .await
157 .map_err(|e| {
158 let error_message = e.message().to_string();
159 let error_type = match e.error_type() {
160 UtilErrorType::TmcHttpError => ControllerErrorType::InternalServerError,
161 UtilErrorType::TmcErrorResponse => ControllerErrorType::BadRequest,
162 _ => ControllerErrorType::InternalServerError,
163 };
164 ControllerError::new(error_type, error_message, Some(anyhow!(e)))
165 })?;
166 let password_secret = SecretString::new(user_details.password.into());
167
168 let user = models::users::insert_with_upstream_id_and_moocfi_id(
169 &mut conn,
170 &user_details.email,
171 Some(&user_details.first_name),
172 Some(&user_details.last_name),
173 upstream_id,
174 PKeyPolicy::Generate.into_uuid(),
175 )
176 .await
177 .map_err(|e| {
178 ControllerError::new(
179 ControllerErrorType::InternalServerError,
180 "Failed to insert user.".to_string(),
181 Some(anyhow!(e)),
182 )
183 })?;
184
185 let country = user_details.country.clone();
186 models::user_details::update_user_country(&mut conn, user.id, &country).await?;
187 models::user_details::update_user_email_communication_consent(
188 &mut conn,
189 user.id,
190 user_details.email_communication_consent,
191 )
192 .await?;
193
194 let password_hash = models::user_passwords::hash_password(&password_secret)
196 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
197
198 models::user_passwords::upsert_user_password(&mut conn, user.id, &password_hash)
199 .await
200 .map_err(|e| {
201 ControllerError::new(
202 ControllerErrorType::InternalServerError,
203 "Failed to add password to database".to_string(),
204 anyhow!(e),
205 )
206 })?;
207
208 tmc_client
210 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user.id)
211 .await
212 .map_err(|e| {
213 ControllerError::new(
214 ControllerErrorType::InternalServerError,
215 "Failed to notify TMC that user's password is saved in courses.mooc.fi"
216 .to_string(),
217 anyhow!(e),
218 )
219 })?;
220
221 let token = skip_authorize();
222 authorization::remember(&session, user)?;
223 token.authorized_ok(HttpResponse::Ok().finish())
224 } else {
225 Err(ControllerError::new(
226 ControllerErrorType::BadRequest,
227 "Cannot create a new account when signed in.".to_string(),
228 None,
229 ))
230 }
231}
232
233async fn handle_test_mode_signup(
234 conn: &mut PgConnection,
235 session: &Session,
236 user_details: &CreateAccountDetails,
237 app_conf: &ApplicationConfiguration,
238) -> ControllerResult<HttpResponse> {
239 assert!(
240 app_conf.test_mode,
241 "handle_test_mode_signup called outside test mode"
242 );
243
244 warn!("Handling signup in test mode. No real account is created.");
245
246 let user_id = models::users::insert(
247 conn,
248 PKeyPolicy::Generate,
249 &user_details.email,
250 Some(&user_details.first_name),
251 Some(&user_details.last_name),
252 )
253 .await
254 .map_err(|e| {
255 ControllerError::new(
256 ControllerErrorType::InternalServerError,
257 "Failed to insert test user.".to_string(),
258 Some(anyhow!(e)),
259 )
260 })?;
261
262 models::user_details::update_user_country(conn, user_id, &user_details.country).await?;
263 models::user_details::update_user_email_communication_consent(
264 conn,
265 user_id,
266 user_details.email_communication_consent,
267 )
268 .await?;
269
270 let user = models::users::get_by_email(conn, &user_details.email).await?;
271
272 let password_hash = models::user_passwords::hash_password(&SecretString::new(
273 user_details.password.clone().into(),
274 ))
275 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
276
277 models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
278 .await
279 .map_err(|e| {
280 ControllerError::new(
281 ControllerErrorType::InternalServerError,
282 "Failed to add password to database".to_string(),
283 anyhow!(e),
284 )
285 })?;
286 authorization::remember(session, user)?;
287
288 let token = skip_authorize();
289 token.authorized_ok(HttpResponse::Ok().finish())
290}
291
292#[utoipa::path(
298 post,
299 path = "/authorize-multiple",
300 tag = "auth",
301 operation_id = "postAuthAuthorizeMultiple",
302 request_body = Vec<ActionOnResource>,
303 responses(
304 (status = 200, description = "Authorization result for each input action, in order", body = Vec<bool>)
305 )
306)]
307#[instrument(skip(pool, payload,))]
308pub async fn authorize_multiple_actions_on_resources(
309 pool: web::Data<PgPool>,
310 user: Option<AuthUser>,
311 payload: web::Json<Vec<ActionOnResource>>,
312) -> ControllerResult<web::Json<Vec<bool>>> {
313 let mut conn = pool.acquire().await?;
314 let input = payload.into_inner();
315 let mut results = Vec::with_capacity(input.len());
316 if let Some(user) = user {
317 let user_roles = models::roles::get_roles(&mut conn, user.id).await?;
319
320 for action_on_resource in input {
321 if (authorize_with_fetched_list_of_roles(
322 &mut conn,
323 action_on_resource.action,
324 Some(user.id),
325 action_on_resource.resource,
326 &user_roles,
327 )
328 .await)
329 .is_ok()
330 {
331 results.push(true);
332 } else {
333 results.push(false);
334 }
335 }
336 } else {
337 for _action_on_resource in input {
339 results.push(false);
340 }
341 }
342 let token = skip_authorize();
343 token.authorized_ok(web::Json(results))
344}
345
346#[utoipa::path(
351 post,
352 path = "/login",
353 tag = "auth",
354 operation_id = "postAuthLogin",
355 request_body = Login,
356 responses(
357 (status = 200, description = "Login outcome", body = LoginResponse)
358 )
359)]
360#[instrument(skip(session, pool, client, payload, app_conf, tmc_client))]
361pub async fn login(
362 session: Session,
363 pool: web::Data<PgPool>,
364 client: web::Data<OAuthClient>,
365 app_conf: web::Data<ApplicationConfiguration>,
366 payload: web::Json<Login>,
367 tmc_client: web::Data<TmcClient>,
368) -> ControllerResult<web::Json<LoginResponse>> {
369 let mut conn = pool.acquire().await?;
370 let Login { email, password } = payload.into_inner();
371
372 if app_conf.development_uuid_login {
374 return handle_uuid_login(&session, &mut conn, &email, &app_conf).await;
375 }
376
377 if app_conf.test_mode {
379 return handle_test_mode_login(&session, &mut conn, &email, &password, &app_conf).await;
380 };
381
382 return handle_production_login(
383 &session,
384 &mut conn,
385 &client,
386 &tmc_client,
387 &email,
388 &password,
389 &app_conf,
390 )
391 .await;
392}
393
394async fn handle_uuid_login(
395 session: &Session,
396 conn: &mut PgConnection,
397 email: &str,
398 app_conf: &ApplicationConfiguration,
399) -> ControllerResult<web::Json<LoginResponse>> {
400 warn!("Trying development mode UUID login");
401 let token = skip_authorize();
402
403 if let Ok(id) = Uuid::parse_str(email) {
404 let user = { models::users::get_by_id(conn, id).await? };
405 let is_admin = is_user_global_admin(conn, user.id).await?;
406
407 if app_conf.enable_admin_email_verification && is_admin {
408 return handle_email_verification(conn, &user).await;
409 }
410
411 authorization::remember(session, user)?;
412 token.authorized_ok(web::Json(LoginResponse::Success))
413 } else {
414 warn!("Authentication failed");
415 token.authorized_ok(web::Json(LoginResponse::Failed))
416 }
417}
418
419async fn handle_test_mode_login(
420 session: &Session,
421 conn: &mut PgConnection,
422 email: &str,
423 password: &str,
424 app_conf: &ApplicationConfiguration,
425) -> ControllerResult<web::Json<LoginResponse>> {
426 warn!("Using test credentials. Normal accounts won't work.");
427
428 let user = match models::users::get_by_email(conn, email).await {
429 Ok(u) => u,
430 Err(_) => {
431 warn!("Test user not found for {}", email);
432 let token = skip_authorize();
433 return token.authorized_ok(web::Json(LoginResponse::Failed));
434 }
435 };
436
437 let mut is_authenticated =
438 authorization::authenticate_test_user(conn, email, password, app_conf)
439 .await
440 .map_err(|e| {
441 ControllerError::new(
442 ControllerErrorType::Unauthorized,
443 "Could not find the test user. Have you seeded the database?".to_string(),
444 e,
445 )
446 })?;
447
448 if !is_authenticated {
449 is_authenticated = models::user_passwords::verify_user_password(
450 conn,
451 user.id,
452 &SecretString::new(password.into()),
453 )
454 .await?;
455 }
456
457 if is_authenticated {
458 info!("Authentication successful");
459 let is_admin = is_user_global_admin(conn, user.id).await?;
460
461 if app_conf.enable_admin_email_verification && is_admin {
462 return handle_email_verification(conn, &user).await;
463 }
464
465 authorization::remember(session, user)?;
466 } else {
467 warn!("Authentication failed");
468 }
469
470 let token = skip_authorize();
471 if is_authenticated {
472 token.authorized_ok(web::Json(LoginResponse::Success))
473 } else {
474 token.authorized_ok(web::Json(LoginResponse::Failed))
475 }
476}
477
478async fn handle_production_login(
479 session: &Session,
480 conn: &mut PgConnection,
481 client: &OAuthClient,
482 tmc_client: &TmcClient,
483 email: &str,
484 password: &str,
485 app_conf: &ApplicationConfiguration,
486) -> ControllerResult<web::Json<LoginResponse>> {
487 let mut is_authenticated = false;
488 let mut authenticated_user: Option<headless_lms_models::users::User> = None;
489
490 if let Ok(user) = models::users::get_by_email(conn, email).await {
492 let is_password_stored =
493 models::user_passwords::check_if_users_password_is_stored(conn, user.id).await?;
494 if is_password_stored {
495 is_authenticated = models::user_passwords::verify_user_password(
496 conn,
497 user.id,
498 &SecretString::new(password.into()),
499 )
500 .await?;
501
502 if is_authenticated {
503 info!("Authentication successful");
504 authenticated_user = Some(user);
505 }
506 }
507 }
508
509 if !is_authenticated {
511 let auth_result = authorization::authenticate_tmc_mooc_fi_user(
512 conn,
513 client,
514 email.to_string(),
515 password.to_string(),
516 tmc_client,
517 )
518 .await?;
519
520 if let Some((user, _token)) = auth_result {
521 let password_hash =
523 models::user_passwords::hash_password(&SecretString::new(password.into()))
524 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
525
526 models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
527 .await
528 .map_err(|e| {
529 ControllerError::new(
530 ControllerErrorType::InternalServerError,
531 "Failed to add password to database".to_string(),
532 anyhow!(e),
533 )
534 })?;
535
536 if let Some(upstream_id) = user.upstream_id {
538 tmc_client
539 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user.id)
540 .await
541 .map_err(|e| {
542 ControllerError::new(
543 ControllerErrorType::InternalServerError,
544 "Failed to notify TMC that users password is saved in courses.mooc.fi"
545 .to_string(),
546 anyhow!(e),
547 )
548 })?;
549 } else {
550 warn!("User has no upstream_id; skipping notify to TMC");
551 }
552 info!("Authentication successful");
553 authenticated_user = Some(user);
554 is_authenticated = true;
555 }
556 }
557
558 let token = skip_authorize();
559 if is_authenticated {
560 if let Some(user) = authenticated_user {
561 let is_admin = is_user_global_admin(conn, user.id).await?;
562
563 if app_conf.enable_admin_email_verification && is_admin {
564 return handle_email_verification(conn, &user).await;
565 }
566
567 authorization::remember(session, user)?;
568 }
569 token.authorized_ok(web::Json(LoginResponse::Success))
570 } else {
571 warn!("Authentication failed");
572 token.authorized_ok(web::Json(LoginResponse::Failed))
573 }
574}
575
576#[utoipa::path(
580 post,
581 path = "/logout",
582 tag = "auth",
583 operation_id = "postAuthLogout",
584 responses((status = 200, description = "Session cleared"))
585)]
586#[instrument(skip(session))]
587#[allow(clippy::async_yields_async)]
588pub async fn logout(session: Session) -> HttpResponse {
589 authorization::forget(&session);
590 HttpResponse::Ok().finish()
591}
592
593#[utoipa::path(
597 get,
598 path = "/logged-in",
599 tag = "auth",
600 operation_id = "getAuthLoggedIn",
601 responses(
602 (status = 200, description = "True when an authenticated session exists", body = bool)
603 )
604)]
605#[instrument(skip(session))]
606pub async fn logged_in(session: Session, pool: web::Data<PgPool>) -> web::Json<bool> {
607 let logged_in = authorization::has_auth_user_session(&session, pool).await;
608 web::Json(logged_in)
609}
610
611#[derive(Debug, Serialize, Deserialize, ToSchema)]
615
616pub struct UserInfo {
617 pub user_id: Uuid,
618 pub first_name: Option<String>,
619 pub last_name: Option<String>,
620}
621
622#[utoipa::path(
627 get,
628 path = "/user-info",
629 tag = "auth",
630 operation_id = "getAuthUserInfo",
631 responses(
632 (status = 200, description = "Profile when signed in; null when anonymous", body = Option<UserInfo>)
633 )
634)]
635#[instrument(skip(auth_user, pool))]
636pub async fn user_info(
637 auth_user: Option<AuthUser>,
638 pool: web::Data<PgPool>,
639) -> ControllerResult<web::Json<Option<UserInfo>>> {
640 let token = skip_authorize();
641 if let Some(auth_user) = auth_user {
642 let mut conn = pool.acquire().await?;
643 let user_details =
644 models::user_details::get_user_details_by_user_id(&mut conn, auth_user.id).await?;
645
646 token.authorized_ok(web::Json(Some(UserInfo {
647 user_id: user_details.user_id,
648 first_name: user_details.first_name,
649 last_name: user_details.last_name,
650 })))
651 } else {
652 token.authorized_ok(web::Json(None))
653 }
654}
655
656#[derive(Debug, Serialize, Deserialize, ToSchema)]
657
658pub struct SendEmailCodeData {
659 pub email: String,
660 pub password: String,
661 pub language: String,
662}
663
664#[utoipa::path(
668 post,
669 path = "/send-email-code",
670 tag = "auth",
671 operation_id = "postAuthSendEmailCode",
672 request_body = SendEmailCodeData,
673 responses(
674 (status = 200, description = "Whether a deletion code email was queued", body = bool)
675 )
676)]
677#[instrument(skip(pool, payload, auth_user))]
678#[allow(clippy::async_yields_async)]
679pub async fn send_delete_user_email_code(
680 auth_user: Option<AuthUser>,
681 pool: web::Data<PgPool>,
682 payload: web::Json<SendEmailCodeData>,
683) -> ControllerResult<web::Json<bool>> {
684 let token = skip_authorize();
685
686 if let Some(auth_user) = auth_user {
688 let mut conn = pool.acquire().await?;
689
690 let password_ok = user_passwords::verify_user_password(
691 &mut conn,
692 auth_user.id,
693 &SecretString::new(payload.password.clone().into()),
694 )
695 .await?;
696
697 if !password_ok {
698 info!(
699 "User {} attempted account deletion with incorrect password",
700 auth_user.id
701 );
702
703 return token.authorized_ok(web::Json(false));
704 }
705
706 let language = &payload.language;
707
708 let delete_template = models::email_templates::get_generic_email_template_by_type_and_language(
710 &mut conn,
711 EmailTemplateType::DeleteUserEmail,
712 language,
713 )
714 .await
715 .map_err(|_e| {
716 anyhow::anyhow!(
717 "Account deletion email template not configured. Missing template 'delete-user-email' for language '{}'",
718 language
719 )
720 })?;
721
722 let user = models::users::get_by_id(&mut conn, auth_user.id).await?;
723
724 let code = if let Some(existing) =
725 models::user_email_codes::get_unused_user_email_code_with_user_id(
726 &mut conn,
727 auth_user.id,
728 )
729 .await?
730 {
731 existing.code
732 } else {
733 let new_code: String = rand::rng().random_range(100_000..1_000_000).to_string();
734 models::user_email_codes::insert_user_email_code(
735 &mut conn,
736 auth_user.id,
737 new_code.clone(),
738 )
739 .await?;
740 new_code
741 };
742
743 models::user_email_codes::insert_user_email_code(&mut conn, auth_user.id, code.clone())
744 .await?;
745 let _ =
746 models::email_deliveries::insert_email_delivery(&mut conn, user.id, delete_template.id)
747 .await?;
748
749 return token.authorized_ok(web::Json(true));
750 }
751 token.authorized_ok(web::Json(false))
752}
753
754#[derive(Debug, Serialize, Deserialize, ToSchema)]
755
756pub struct EmailCode {
757 pub code: String,
758}
759
760#[utoipa::path(
764 post,
765 path = "/delete-user-account",
766 tag = "auth",
767 operation_id = "postAuthDeleteUserAccount",
768 request_body = EmailCode,
769 responses(
770 (status = 200, description = "Whether the account was deleted", body = bool)
771 )
772)]
773#[instrument(skip(pool, payload, auth_user, session))]
774#[allow(clippy::async_yields_async)]
775pub async fn delete_user_account(
776 auth_user: Option<AuthUser>,
777 pool: web::Data<PgPool>,
778 payload: web::Json<EmailCode>,
779 session: Session,
780 tmc_client: web::Data<TmcClient>,
781) -> ControllerResult<web::Json<bool>> {
782 let token = skip_authorize();
783 if let Some(auth_user) = auth_user {
784 let mut conn = pool.acquire().await?;
785
786 let code_ok = user_email_codes::is_reset_user_email_code_valid(
788 &mut conn,
789 auth_user.id,
790 &payload.code,
791 )
792 .await?;
793
794 if !code_ok {
795 info!(
796 "User {} attempted account deletion with incorrect code",
797 auth_user.id
798 );
799 return token.authorized_ok(web::Json(false));
800 }
801
802 let mut tx = conn.begin().await?;
803 let user = users::get_by_id(&mut tx, auth_user.id).await?;
804
805 if let Some(upstream_id) = user.upstream_id {
807 let upstream_id_str = upstream_id.to_string();
808 let tmc_success = tmc_client
809 .delete_user_from_tmc(upstream_id_str)
810 .await
811 .unwrap_or(false);
812
813 if !tmc_success {
814 info!("TMC deletion failed for user {}", auth_user.id);
815 return token.authorized_ok(web::Json(false));
816 }
817 }
818
819 users::delete_user(&mut tx, auth_user.id).await?;
821 user_email_codes::mark_user_email_code_used(&mut tx, auth_user.id, &payload.code).await?;
822
823 tx.commit().await?;
824
825 authorization::forget(&session);
826 token.authorized_ok(web::Json(true))
827 } else {
828 return token.authorized_ok(web::Json(false));
829 }
830}
831
832pub async fn update_user_information_to_tmc(
833 first_name: String,
834 last_name: String,
835 email: Option<String>,
836 user_upstream_id: String,
837 tmc_client: web::Data<TmcClient>,
838 app_conf: web::Data<ApplicationConfiguration>,
839) -> Result<(), Error> {
840 if app_conf.test_mode {
841 return Ok(());
842 }
843 tmc_client
844 .update_user_information(first_name, last_name, email, user_upstream_id)
845 .await
846 .map_err(|e| {
847 log::warn!("TMC user update failed: {:?}", e);
848 anyhow::anyhow!("TMC user update failed: {}", e)
849 })?;
850 Ok(())
851}
852
853pub async fn is_user_global_admin(conn: &mut PgConnection, user_id: Uuid) -> ModelResult<bool> {
854 let roles = models::roles::get_roles(conn, user_id).await?;
855 Ok(roles
856 .iter()
857 .any(|r| r.role == models::roles::UserRole::Admin && r.is_global))
858}
859
860async fn handle_email_verification(
861 conn: &mut PgConnection,
862 user: &headless_lms_models::users::User,
863) -> ControllerResult<web::Json<LoginResponse>> {
864 let code: String = rand::rng().random_range(100_000..1_000_000).to_string();
865
866 let email_verification_token =
867 email_verification_tokens::create_email_verification_token(conn, user.id, code.clone())
868 .await
869 .map_err(|e| {
870 ControllerError::new(
871 ControllerErrorType::InternalServerError,
872 "Failed to create email verification token".to_string(),
873 Some(anyhow!(e)),
874 )
875 })?;
876
877 user_email_codes::insert_user_email_code(conn, user.id, code.clone())
878 .await
879 .map_err(|e| {
880 ControllerError::new(
881 ControllerErrorType::InternalServerError,
882 "Failed to insert user email code".to_string(),
883 Some(anyhow!(e)),
884 )
885 })?;
886
887 let email_template = models::email_templates::get_generic_email_template_by_type_and_language(
888 conn,
889 EmailTemplateType::ConfirmEmailCode,
890 "en",
891 )
892 .await
893 .map_err(|e| {
894 ControllerError::new(
895 ControllerErrorType::InternalServerError,
896 format!("Failed to get email template: {}", e.message()),
897 Some(anyhow!(e)),
898 )
899 })?;
900
901 models::email_deliveries::insert_email_delivery(conn, user.id, email_template.id)
902 .await
903 .map_err(|e| {
904 ControllerError::new(
905 ControllerErrorType::InternalServerError,
906 "Failed to insert email delivery".to_string(),
907 Some(anyhow!(e)),
908 )
909 })?;
910
911 email_verification_tokens::mark_code_sent(conn, &email_verification_token)
912 .await
913 .map_err(|e| {
914 ControllerError::new(
915 ControllerErrorType::InternalServerError,
916 "Failed to mark code as sent".to_string(),
917 Some(anyhow!(e)),
918 )
919 })?;
920
921 let token = skip_authorize();
922 token.authorized_ok(web::Json(LoginResponse::RequiresEmailVerification {
923 email_verification_token,
924 }))
925}
926
927#[derive(Debug, Serialize, Deserialize, ToSchema)]
928
929pub struct VerifyEmailRequest {
930 pub email_verification_token: String,
931 pub code: String,
932}
933
934#[utoipa::path(
938 post,
939 path = "/verify-email",
940 tag = "auth",
941 operation_id = "postAuthVerifyEmail",
942 request_body = VerifyEmailRequest,
943 responses(
944 (status = 200, description = "Whether verification succeeded", body = bool)
945 )
946)]
947#[instrument(skip(session, pool, payload))]
948pub async fn verify_email(
949 session: Session,
950 pool: web::Data<PgPool>,
951 payload: web::Json<VerifyEmailRequest>,
952) -> ControllerResult<web::Json<bool>> {
953 let mut conn = pool.acquire().await?;
954 let payload = payload.into_inner();
955
956 let token = email_verification_tokens::get_by_email_verification_token(
957 &mut conn,
958 &payload.email_verification_token,
959 )
960 .await
961 .map_err(|e| {
962 ControllerError::new(
963 ControllerErrorType::InternalServerError,
964 "Failed to get email verification token".to_string(),
965 Some(anyhow!(e)),
966 )
967 })?;
968
969 let Some(token_value) = token else {
970 let skip_token = skip_authorize();
971 return skip_token.authorized_ok(web::Json(false));
972 };
973
974 let is_valid = email_verification_tokens::verify_code(
975 &mut conn,
976 &payload.email_verification_token,
977 &payload.code,
978 )
979 .await
980 .map_err(|e| {
981 ControllerError::new(
982 ControllerErrorType::InternalServerError,
983 "Failed to verify code".to_string(),
984 Some(anyhow!(e)),
985 )
986 })?;
987
988 if !is_valid {
989 let skip_token = skip_authorize();
990 return skip_token.authorized_ok(web::Json(false));
991 }
992
993 let user_id = token_value.user_id;
994
995 user_email_codes::mark_user_email_code_used(&mut conn, user_id, &payload.code)
996 .await
997 .map_err(|e| {
998 ControllerError::new(
999 ControllerErrorType::InternalServerError,
1000 "Failed to mark user email code as used".to_string(),
1001 Some(anyhow!(e)),
1002 )
1003 })?;
1004
1005 email_verification_tokens::mark_as_used(&mut conn, &payload.email_verification_token)
1006 .await
1007 .map_err(|e| {
1008 ControllerError::new(
1009 ControllerErrorType::InternalServerError,
1010 "Failed to mark token as used".to_string(),
1011 Some(anyhow!(e)),
1012 )
1013 })?;
1014
1015 let user = models::users::get_by_id(&mut conn, user_id)
1016 .await
1017 .map_err(|e| {
1018 ControllerError::new(
1019 ControllerErrorType::InternalServerError,
1020 "Failed to get user".to_string(),
1021 Some(anyhow!(e)),
1022 )
1023 })?;
1024
1025 authorization::remember(&session, user)?;
1026
1027 let skip_token = skip_authorize();
1028 skip_token.authorized_ok(web::Json(true))
1029}
1030
1031#[derive(OpenApi)]
1032#[openapi(
1033 paths(
1034 signup,
1035 login,
1036 logout,
1037 logged_in,
1038 authorize_action_on_resource,
1039 authorize_multiple_actions_on_resources,
1040 user_info,
1041 send_delete_user_email_code,
1042 delete_user_account,
1043 verify_email,
1044 ),
1045 components(schemas(
1046 Login,
1047 LoginResponse,
1048 CreateAccountDetails,
1049 UserInfo,
1050 crate::domain::authorization::ActionOnResource,
1051 crate::domain::authorization::Action,
1052 crate::domain::authorization::Resource,
1053 SendEmailCodeData,
1054 EmailCode,
1055 VerifyEmailRequest,
1056 headless_lms_models::roles::UserRole,
1057 ))
1058)]
1059pub struct AuthRoutesApiDoc;
1060
1061pub fn _add_routes(cfg: &mut ServiceConfig) {
1062 cfg.service(
1063 web::resource("/signup")
1064 .wrap(RateLimit::new(RateLimitConfig {
1065 per_minute: Some(15),
1066 per_hour: None,
1067 per_day: Some(1000),
1068 per_month: None,
1069 ..Default::default()
1070 }))
1071 .to(signup),
1072 )
1073 .service(
1074 web::resource("/login")
1075 .wrap(RateLimit::new(RateLimitConfig {
1076 per_minute: Some(20),
1077 per_hour: Some(100),
1078 per_day: Some(500),
1079 per_month: None,
1080 ..Default::default()
1081 }))
1082 .to(login),
1083 )
1084 .route("/logout", web::post().to(logout))
1085 .route("/logged-in", web::get().to(logged_in))
1086 .route("/authorize", web::post().to(authorize_action_on_resource))
1087 .route(
1088 "/authorize-multiple",
1089 web::post().to(authorize_multiple_actions_on_resources),
1090 )
1091 .route("/user-info", web::get().to(user_info))
1092 .service(
1093 web::resource("/delete-user-account")
1094 .wrap(RateLimit::new(RateLimitConfig {
1095 per_minute: None,
1096 per_hour: Some(5),
1097 per_day: Some(10),
1098 per_month: None,
1099 ..Default::default()
1100 }))
1101 .to(delete_user_account),
1102 )
1103 .service(
1104 web::resource("/send-email-code")
1105 .wrap(RateLimit::new(RateLimitConfig {
1106 per_minute: None,
1107 per_hour: Some(5),
1108 per_day: Some(20),
1109 per_month: None,
1110 ..Default::default()
1111 }))
1112 .to(send_delete_user_email_code),
1113 )
1114 .service(
1115 web::resource("/verify-email")
1116 .wrap(RateLimit::new(RateLimitConfig {
1117 per_minute: Some(10),
1118 per_hour: Some(50),
1119 per_day: None,
1120 per_month: None,
1121 ..Default::default()
1122 }))
1123 .to(verify_email),
1124 );
1125}