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