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_email_codes::UserEmailCodePurpose, user_passwords, users,
22};
23use headless_lms_utils::{
24 prelude::UtilErrorType,
25 services::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)
222 if matches!(
223 error.error_type(),
224 ModelErrorType::DatabaseConstraint { constraint, .. }
225 if constraint == "users_email"
226 ) =>
227 {
228 let token = skip_authorize();
229 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
230 }
231 Err(error)
235 if matches!(
236 error.error_type(),
237 ModelErrorType::DatabaseConstraint { constraint, .. }
238 if constraint == "users_upstream_id_active_uniq_idx"
239 ) =>
240 {
241 models::users::find_by_upstream_id(&mut conn, upstream_id)
242 .await?
243 .ok_or(error)?
244 }
245 Err(error) => {
246 return Err(controller_err!(
247 InternalServerError,
248 "Failed to insert user.".to_string(),
249 anyhow!(error)
250 ));
251 }
252 };
253
254 let country = user_details.country.clone();
255 models::user_details::update_user_country(&mut conn, user.id, &country).await?;
256 models::user_details::update_user_email_communication_consent(
257 &mut conn,
258 user.id,
259 user_details.email_communication_consent,
260 )
261 .await?;
262
263 let password_hash = models::user_passwords::hash_password(&password_secret)
265 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
266
267 models::user_passwords::upsert_user_password(&mut conn, user.id, &password_hash)
268 .await
269 .map_err(|e| {
270 ControllerError::new(
271 ControllerErrorType::InternalServerError,
272 "Failed to add password to database".to_string(),
273 anyhow!(e),
274 )
275 })?;
276
277 crate::controllers::tmc_server::notify_password_managed_with_retry(
281 &tmc_client,
282 upstream_id.to_string(),
283 user.id,
284 )
285 .await;
286
287 domain::email_ownership_verification::queue_verification_email_best_effort(
289 &mut conn,
290 app_conf.enable_email_ownership_verification,
291 user.id,
292 )
293 .await;
294
295 let token = skip_authorize();
296 authorization::remember(&session, user)?;
297 token.authorized_ok(web::Json(SignupResponse::Success))
298 } else {
299 Err(ControllerError::new(
300 ControllerErrorType::BadRequest,
301 "Cannot create a new account when signed in.".to_string(),
302 None,
303 ))
304 }
305}
306
307async fn handle_test_mode_signup(
308 conn: &mut PgConnection,
309 session: &Session,
310 user_details: &CreateAccountDetails,
311 app_conf: &ApplicationConfiguration,
312) -> ControllerResult<web::Json<SignupResponse>> {
313 assert!(
314 app_conf.test_mode,
315 "handle_test_mode_signup called outside test mode"
316 );
317
318 warn!("Handling signup in test mode. No real account is created.");
319
320 match models::users::get_by_email(conn, &user_details.email).await {
321 Ok(_) => {
322 let token = skip_authorize();
323 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
324 }
325 Err(error)
326 if matches!(
327 error.error_type(),
328 ModelErrorType::RecordNotFound | ModelErrorType::NotFound
329 ) => {}
330 Err(error) => return Err(error.into()),
331 }
332
333 let user_id = models::users::insert(
334 conn,
335 PKeyPolicy::Generate,
336 &user_details.email,
337 Some(&user_details.first_name),
338 Some(&user_details.last_name),
339 )
340 .await;
341 let user_id = match user_id {
342 Ok(user_id) => user_id,
343 Err(error) => match error.error_type() {
344 ModelErrorType::DatabaseConstraint { constraint, .. }
345 if constraint == "users_email" =>
346 {
347 let token = skip_authorize();
348 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
349 }
350 _ => {
351 return Err(controller_err!(
352 InternalServerError,
353 "Failed to insert test user.".to_string(),
354 anyhow!(error)
355 ));
356 }
357 },
358 };
359
360 models::user_details::update_user_country(conn, user_id, &user_details.country).await?;
361 models::user_details::update_user_email_communication_consent(
362 conn,
363 user_id,
364 user_details.email_communication_consent,
365 )
366 .await?;
367
368 let user = models::users::get_by_email(conn, &user_details.email).await?;
369
370 let password_hash = models::user_passwords::hash_password(&user_details.password)
371 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
372
373 models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
374 .await
375 .map_err(|e| {
376 ControllerError::new(
377 ControllerErrorType::InternalServerError,
378 "Failed to add password to database".to_string(),
379 anyhow!(e),
380 )
381 })?;
382 domain::email_ownership_verification::queue_verification_email_best_effort(
383 conn,
384 app_conf.enable_email_ownership_verification,
385 user.id,
386 )
387 .await;
388
389 authorization::remember(session, user)?;
390
391 let token = skip_authorize();
392 token.authorized_ok(web::Json(SignupResponse::Success))
393}
394
395fn is_duplicate_email_error_message(message: &str) -> bool {
396 let normalized = message.to_lowercase();
397 normalized.contains("email already exists")
398 || normalized.contains("email is already registered")
399 || normalized.contains("email already in use")
400 || normalized.contains("duplicate email")
401 || normalized.contains("unique constraint")
402 || normalized.contains("duplicate key")
403 || normalized.contains("users_email")
404 || normalized.contains("email_key")
405}
406
407#[utoipa::path(
413 post,
414 path = "/authorize-multiple",
415 tag = "auth",
416 operation_id = "postAuthAuthorizeMultiple",
417 request_body = Vec<ActionOnResource>,
418 responses(
419 (status = 200, description = "Authorization result for each input action, in order", body = Vec<bool>)
420 )
421)]
422#[instrument(skip(pool, payload,))]
423pub async fn authorize_multiple_actions_on_resources(
424 pool: web::Data<PgPool>,
425 user: Option<AuthUser>,
426 payload: web::Json<Vec<ActionOnResource>>,
427) -> ControllerResult<web::Json<Vec<bool>>> {
428 let mut conn = pool.acquire().await?;
429 let input = payload.into_inner();
430 let mut results = Vec::with_capacity(input.len());
431 if let Some(user) = user {
432 let user_roles = models::roles::get_roles(&mut conn, user.id).await?;
434
435 for action_on_resource in input {
436 if (authorize_with_fetched_list_of_roles(
437 &mut conn,
438 action_on_resource.action,
439 Some(user.id),
440 action_on_resource.resource,
441 &user_roles,
442 )
443 .await)
444 .is_ok()
445 {
446 results.push(true);
447 } else {
448 results.push(false);
449 }
450 }
451 } else {
452 for _action_on_resource in input {
454 results.push(false);
455 }
456 }
457 let token = skip_authorize();
458 token.authorized_ok(web::Json(results))
459}
460
461#[utoipa::path(
466 post,
467 path = "/login",
468 tag = "auth",
469 operation_id = "postAuthLogin",
470 request_body = Login,
471 responses(
472 (status = 200, description = "Login outcome", body = LoginResponse)
473 )
474)]
475#[instrument(skip(session, pool, client, payload, app_conf, tmc_client))]
476pub async fn login(
477 session: Session,
478 pool: web::Data<PgPool>,
479 client: web::Data<OAuthClient>,
480 app_conf: web::Data<ApplicationConfiguration>,
481 payload: web::Json<Login>,
482 tmc_client: web::Data<TmcClient>,
483) -> ControllerResult<web::Json<LoginResponse>> {
484 let mut conn = pool.acquire().await?;
485 let Login { email, password } = payload.into_inner();
486
487 if app_conf.development_uuid_login {
489 return handle_uuid_login(&session, &mut conn, &email, &app_conf).await;
490 }
491
492 if app_conf.test_mode {
494 return handle_test_mode_login(&session, &mut conn, &email, &password, &app_conf).await;
495 };
496
497 return handle_production_login(
498 &session,
499 &mut conn,
500 &client,
501 &tmc_client,
502 &email,
503 &password,
504 &app_conf,
505 )
506 .await;
507}
508
509async fn handle_uuid_login(
510 session: &Session,
511 conn: &mut PgConnection,
512 email: &str,
513 app_conf: &ApplicationConfiguration,
514) -> ControllerResult<web::Json<LoginResponse>> {
515 warn!("Trying development mode UUID login");
516 let token = skip_authorize();
517
518 if let Ok(id) = Uuid::parse_str(email) {
519 let user = { models::users::get_by_id(conn, id).await? };
520 let is_admin = is_user_global_admin(conn, user.id).await?;
521
522 if app_conf.enable_admin_email_verification && is_admin {
523 return handle_email_verification(conn, &user).await;
524 }
525
526 authorization::remember(session, user)?;
527 token.authorized_ok(web::Json(LoginResponse::Success))
528 } else {
529 warn!("Authentication failed");
530 token.authorized_ok(web::Json(LoginResponse::Failed))
531 }
532}
533
534async fn handle_test_mode_login(
535 session: &Session,
536 conn: &mut PgConnection,
537 email: &str,
538 password: &SecretString,
539 app_conf: &ApplicationConfiguration,
540) -> ControllerResult<web::Json<LoginResponse>> {
541 warn!("Using test credentials. Normal accounts won't work.");
542
543 let user = match models::users::get_by_email(conn, email).await {
544 Ok(u) => u,
545 Err(_) => {
546 warn!("Test user not found for {}", email);
547 let token = skip_authorize();
548 return token.authorized_ok(web::Json(LoginResponse::Failed));
549 }
550 };
551
552 let mut is_authenticated =
553 authorization::authenticate_test_user(conn, email, password, app_conf)
554 .await
555 .map_err(|e| {
556 ControllerError::new(
557 ControllerErrorType::Unauthorized,
558 "Could not find the test user. Have you seeded the database?".to_string(),
559 e,
560 )
561 })?;
562
563 if !is_authenticated {
564 is_authenticated =
565 models::user_passwords::verify_user_password(conn, user.id, password).await?;
566 }
567
568 if is_authenticated {
569 info!("Authentication successful");
570 let is_admin = is_user_global_admin(conn, user.id).await?;
571
572 if app_conf.enable_admin_email_verification && is_admin {
573 return handle_email_verification(conn, &user).await;
574 }
575
576 authorization::remember(session, user)?;
577 } else {
578 warn!("Authentication failed");
579 }
580
581 let token = skip_authorize();
582 if is_authenticated {
583 token.authorized_ok(web::Json(LoginResponse::Success))
584 } else {
585 token.authorized_ok(web::Json(LoginResponse::Failed))
586 }
587}
588
589async fn handle_production_login(
590 session: &Session,
591 conn: &mut PgConnection,
592 client: &OAuthClient,
593 tmc_client: &TmcClient,
594 email: &str,
595 password: &SecretString,
596 app_conf: &ApplicationConfiguration,
597) -> ControllerResult<web::Json<LoginResponse>> {
598 let email = email.trim();
601 let mut is_authenticated = false;
602 let mut authenticated_user: Option<headless_lms_models::users::User> = None;
603
604 if let Ok(user) = models::users::get_by_email(conn, email).await {
606 let is_password_stored =
607 models::user_passwords::check_if_users_password_is_stored(conn, user.id).await?;
608 if is_password_stored {
609 is_authenticated =
610 models::user_passwords::verify_user_password(conn, user.id, password).await?;
611
612 if is_authenticated {
613 info!("Authentication successful");
614 authenticated_user = Some(user);
615 }
616 }
617 }
618
619 if !is_authenticated {
621 let auth_result = authorization::authenticate_tmc_mooc_fi_user(
622 conn,
623 client,
624 email.to_string(),
625 password.clone(),
626 tmc_client,
627 )
628 .await?;
629
630 if let Some((user, _token)) = auth_result {
631 let password_hash = models::user_passwords::hash_password(password)
633 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
634
635 models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
636 .await
637 .map_err(|e| {
638 ControllerError::new(
639 ControllerErrorType::InternalServerError,
640 "Failed to add password to database".to_string(),
641 anyhow!(e),
642 )
643 })?;
644
645 if let Some(upstream_id) = user.upstream_id {
649 crate::controllers::tmc_server::notify_password_managed_with_retry(
650 tmc_client,
651 upstream_id.to_string(),
652 user.id,
653 )
654 .await;
655 } else {
656 warn!("User has no upstream_id; skipping notify to TMC");
657 }
658 info!("Authentication successful");
659 authenticated_user = Some(user);
660 is_authenticated = true;
661 }
662 }
663
664 let token = skip_authorize();
665 if is_authenticated {
666 if let Some(user) = authenticated_user {
667 let is_admin = is_user_global_admin(conn, user.id).await?;
668
669 if app_conf.enable_admin_email_verification && is_admin {
670 return handle_email_verification(conn, &user).await;
671 }
672
673 authorization::remember(session, user)?;
674 }
675 token.authorized_ok(web::Json(LoginResponse::Success))
676 } else {
677 warn!("Authentication failed");
678 token.authorized_ok(web::Json(LoginResponse::Failed))
679 }
680}
681
682#[utoipa::path(
686 post,
687 path = "/logout",
688 tag = "auth",
689 operation_id = "postAuthLogout",
690 responses((status = 200, description = "Session cleared"))
691)]
692#[instrument(skip(session))]
693#[allow(clippy::async_yields_async)]
694pub async fn logout(session: Session) -> HttpResponse {
695 authorization::forget(&session);
696 HttpResponse::Ok().finish()
697}
698
699#[utoipa::path(
703 get,
704 path = "/logged-in",
705 tag = "auth",
706 operation_id = "getAuthLoggedIn",
707 responses(
708 (status = 200, description = "True when an authenticated session exists", body = bool)
709 )
710)]
711#[instrument(skip(session))]
712pub async fn logged_in(session: Session, pool: web::Data<PgPool>) -> web::Json<bool> {
713 let logged_in = authorization::has_auth_user_session(&session, pool).await;
714 web::Json(logged_in)
715}
716
717#[derive(Debug, Serialize, Deserialize, ToSchema)]
721
722pub struct UserInfo {
723 pub user_id: Uuid,
724 pub first_name: Option<String>,
725 pub last_name: Option<String>,
726}
727
728#[utoipa::path(
733 get,
734 path = "/user-info",
735 tag = "auth",
736 operation_id = "getAuthUserInfo",
737 responses(
738 (status = 200, description = "Profile when signed in; null when anonymous", body = Option<UserInfo>)
739 )
740)]
741#[instrument(skip(auth_user, pool))]
742pub async fn user_info(
743 auth_user: Option<AuthUser>,
744 pool: web::Data<PgPool>,
745) -> ControllerResult<web::Json<Option<UserInfo>>> {
746 let token = skip_authorize();
747 if let Some(auth_user) = auth_user {
748 let mut conn = pool.acquire().await?;
749 let user_details =
750 models::user_details::get_user_details_by_user_id(&mut conn, auth_user.id).await?;
751
752 token.authorized_ok(web::Json(Some(UserInfo {
753 user_id: user_details.user_id,
754 first_name: user_details.first_name,
755 last_name: user_details.last_name,
756 })))
757 } else {
758 token.authorized_ok(web::Json(None))
759 }
760}
761
762#[derive(Debug, Deserialize, ToSchema)]
763pub struct SendEmailCodeData {
764 pub email: String,
765 #[schema(value_type = String)]
766 pub password: SecretString,
767 pub language: String,
768}
769
770#[utoipa::path(
774 post,
775 path = "/send-email-code",
776 tag = "auth",
777 operation_id = "postAuthSendEmailCode",
778 request_body = SendEmailCodeData,
779 responses(
780 (status = 200, description = "Whether a deletion code email was queued", body = bool)
781 )
782)]
783#[instrument(skip(pool, payload, auth_user))]
784#[allow(clippy::async_yields_async)]
785pub async fn send_delete_user_email_code(
786 auth_user: Option<AuthUser>,
787 pool: web::Data<PgPool>,
788 payload: web::Json<SendEmailCodeData>,
789) -> ControllerResult<web::Json<bool>> {
790 let token = skip_authorize();
791
792 if let Some(auth_user) = auth_user {
794 let mut conn = pool.acquire().await?;
795
796 let password_ok =
797 user_passwords::verify_user_password(&mut conn, auth_user.id, &payload.password)
798 .await?;
799
800 if !password_ok {
801 info!(
802 "User {} attempted account deletion with incorrect password",
803 auth_user.id
804 );
805
806 return token.authorized_ok(web::Json(false));
807 }
808
809 let language = &payload.language;
810
811 let delete_template = models::email_templates::get_generic_email_template_by_type_and_language(
813 &mut conn,
814 EmailTemplateType::DeleteUserEmail,
815 language,
816 )
817 .await
818 .map_err(|_e| {
819 anyhow::anyhow!(
820 "Account deletion email template not configured. Missing template 'delete-user-email' for language '{}'",
821 language
822 )
823 })?;
824
825 let user = models::users::get_by_id(&mut conn, auth_user.id).await?;
826
827 let code = match models::user_email_codes::get_unused_user_email_code_with_user_id(
828 &mut conn,
829 auth_user.id,
830 UserEmailCodePurpose::AccountDeletion,
831 )
832 .await?
833 {
834 Some(existing) => existing.code,
835 None => models::user_email_codes::generate_code(),
836 };
837
838 models::user_email_codes::insert_user_email_code(
839 &mut conn,
840 auth_user.id,
841 UserEmailCodePurpose::AccountDeletion,
842 &code,
843 )
844 .await?;
845 let _ =
846 models::email_deliveries::insert_email_delivery(&mut conn, user.id, delete_template.id)
847 .await?;
848
849 return token.authorized_ok(web::Json(true));
850 }
851 token.authorized_ok(web::Json(false))
852}
853
854#[derive(Debug, Deserialize, ToSchema)]
855
856pub struct EmailCode {
857 #[schema(value_type = String)]
858 pub code: DbSecret,
859}
860
861#[utoipa::path(
865 post,
866 path = "/delete-user-account",
867 tag = "auth",
868 operation_id = "postAuthDeleteUserAccount",
869 request_body = EmailCode,
870 responses(
871 (status = 200, description = "Whether the account was deleted", body = bool)
872 )
873)]
874#[instrument(skip(pool, payload, auth_user, session))]
875#[allow(clippy::async_yields_async)]
876pub async fn delete_user_account(
877 auth_user: Option<AuthUser>,
878 pool: web::Data<PgPool>,
879 payload: web::Json<EmailCode>,
880 session: Session,
881 tmc_client: web::Data<TmcClient>,
882) -> ControllerResult<web::Json<bool>> {
883 let token = skip_authorize();
884 if let Some(auth_user) = auth_user {
885 let mut conn = pool.acquire().await?;
886
887 let code_ok = user_email_codes::is_reset_user_email_code_valid(
889 &mut conn,
890 auth_user.id,
891 UserEmailCodePurpose::AccountDeletion,
892 &payload.code,
893 )
894 .await?;
895
896 if !code_ok {
897 info!(
898 "User {} attempted account deletion with incorrect code",
899 auth_user.id
900 );
901 return token.authorized_ok(web::Json(false));
902 }
903
904 let mut tx = conn.begin().await?;
905 let user = users::get_by_id(&mut tx, auth_user.id).await?;
906
907 if let Some(upstream_id) = user.upstream_id {
909 let upstream_id_str = upstream_id.to_string();
910 let tmc_success = tmc_client
911 .delete_user_from_tmc(upstream_id_str)
912 .await
913 .unwrap_or(false);
914
915 if !tmc_success {
916 info!("TMC deletion failed for user {}", auth_user.id);
917 return token.authorized_ok(web::Json(false));
918 }
919 }
920
921 users::delete_user(&mut tx, auth_user.id).await?;
923 user_email_codes::mark_user_email_code_used(
924 &mut tx,
925 auth_user.id,
926 UserEmailCodePurpose::AccountDeletion,
927 &payload.code,
928 )
929 .await?;
930
931 tx.commit().await?;
932
933 authorization::forget(&session);
934 token.authorized_ok(web::Json(true))
935 } else {
936 return token.authorized_ok(web::Json(false));
937 }
938}
939
940pub async fn update_user_information_to_tmc(
941 first_name: String,
942 last_name: String,
943 email: Option<String>,
944 user_upstream_id: String,
945 tmc_client: web::Data<TmcClient>,
946 app_conf: web::Data<ApplicationConfiguration>,
947) -> Result<(), Error> {
948 if app_conf.test_mode {
949 return Ok(());
950 }
951 tmc_client
952 .update_user_information(first_name, last_name, email, user_upstream_id)
953 .await
954 .map_err(|e| {
955 log::warn!("TMC user update failed: {:?}", e);
956 anyhow::anyhow!("TMC user update failed: {}", e)
957 })?;
958 Ok(())
959}
960
961pub async fn is_user_global_admin(conn: &mut PgConnection, user_id: Uuid) -> ModelResult<bool> {
962 let roles = models::roles::get_roles(conn, user_id).await?;
963 Ok(roles
964 .iter()
965 .any(|r| r.role == models::roles::UserRole::Admin && r.is_global))
966}
967
968async fn handle_email_verification(
969 conn: &mut PgConnection,
970 user: &headless_lms_models::users::User,
971) -> ControllerResult<web::Json<LoginResponse>> {
972 let code = user_email_codes::generate_code();
973
974 let email_verification_token =
975 email_verification_tokens::create_email_verification_token(conn, user.id, code.clone())
976 .await
977 .map_err(|e| {
978 ControllerError::new(
979 ControllerErrorType::InternalServerError,
980 "Failed to create email verification token".to_string(),
981 Some(anyhow!(e)),
982 )
983 })?;
984
985 user_email_codes::insert_user_email_code(
986 conn,
987 user.id,
988 UserEmailCodePurpose::AdminLogin,
989 &code,
990 )
991 .await
992 .map_err(|e| {
993 ControllerError::new(
994 ControllerErrorType::InternalServerError,
995 "Failed to insert user email code".to_string(),
996 Some(anyhow!(e)),
997 )
998 })?;
999
1000 let email_template = models::email_templates::get_generic_email_template_by_type_and_language(
1001 conn,
1002 EmailTemplateType::ConfirmEmailCode,
1003 "en",
1004 )
1005 .await
1006 .map_err(|e| {
1007 ControllerError::new(
1008 ControllerErrorType::InternalServerError,
1009 format!("Failed to get email template: {}", e.message()),
1010 Some(anyhow!(e)),
1011 )
1012 })?;
1013
1014 models::email_deliveries::insert_email_delivery(conn, user.id, email_template.id)
1015 .await
1016 .map_err(|e| {
1017 ControllerError::new(
1018 ControllerErrorType::InternalServerError,
1019 "Failed to insert email delivery".to_string(),
1020 Some(anyhow!(e)),
1021 )
1022 })?;
1023
1024 email_verification_tokens::mark_code_sent(conn, &email_verification_token)
1025 .await
1026 .map_err(|e| {
1027 ControllerError::new(
1028 ControllerErrorType::InternalServerError,
1029 "Failed to mark code as sent".to_string(),
1030 Some(anyhow!(e)),
1031 )
1032 })?;
1033
1034 let token = skip_authorize();
1035 token.authorized_ok(web::Json(LoginResponse::RequiresEmailVerification {
1036 email_verification_token: OutboundSecret::new(
1038 email_verification_token.expose_secret().to_string(),
1039 ),
1040 }))
1041}
1042
1043#[derive(Debug, Deserialize, ToSchema)]
1044pub struct VerifyEmailRequest {
1045 #[schema(value_type = String)]
1046 pub email_verification_token: DbSecret,
1047 #[schema(value_type = String)]
1048 pub code: DbSecret,
1049}
1050
1051#[utoipa::path(
1055 post,
1056 path = "/verify-email",
1057 tag = "auth",
1058 operation_id = "postAuthVerifyEmail",
1059 request_body = VerifyEmailRequest,
1060 responses(
1061 (status = 200, description = "Whether verification succeeded", body = bool)
1062 )
1063)]
1064#[instrument(skip(session, pool, payload))]
1065pub async fn verify_email(
1066 session: Session,
1067 pool: web::Data<PgPool>,
1068 payload: web::Json<VerifyEmailRequest>,
1069) -> ControllerResult<web::Json<bool>> {
1070 let mut conn = pool.acquire().await?;
1071 let payload = payload.into_inner();
1072
1073 let token = email_verification_tokens::get_by_email_verification_token(
1074 &mut conn,
1075 &payload.email_verification_token,
1076 )
1077 .await
1078 .map_err(|e| {
1079 ControllerError::new(
1080 ControllerErrorType::InternalServerError,
1081 "Failed to get email verification token".to_string(),
1082 Some(anyhow!(e)),
1083 )
1084 })?;
1085
1086 let Some(token_value) = token else {
1087 let skip_token = skip_authorize();
1088 return skip_token.authorized_ok(web::Json(false));
1089 };
1090
1091 let is_valid = email_verification_tokens::verify_code(
1092 &mut conn,
1093 &payload.email_verification_token,
1094 &payload.code,
1095 )
1096 .await
1097 .map_err(|e| {
1098 ControllerError::new(
1099 ControllerErrorType::InternalServerError,
1100 "Failed to verify code".to_string(),
1101 Some(anyhow!(e)),
1102 )
1103 })?;
1104
1105 if !is_valid {
1106 let skip_token = skip_authorize();
1107 return skip_token.authorized_ok(web::Json(false));
1108 }
1109
1110 let user_id = token_value.user_id;
1111
1112 user_email_codes::mark_user_email_code_used(
1113 &mut conn,
1114 user_id,
1115 UserEmailCodePurpose::AdminLogin,
1116 &payload.code,
1117 )
1118 .await
1119 .map_err(|e| {
1120 ControllerError::new(
1121 ControllerErrorType::InternalServerError,
1122 "Failed to mark user email code as used".to_string(),
1123 Some(anyhow!(e)),
1124 )
1125 })?;
1126
1127 email_verification_tokens::mark_as_used(&mut conn, &payload.email_verification_token)
1128 .await
1129 .map_err(|e| {
1130 ControllerError::new(
1131 ControllerErrorType::InternalServerError,
1132 "Failed to mark token as used".to_string(),
1133 Some(anyhow!(e)),
1134 )
1135 })?;
1136
1137 let user = models::users::get_by_id(&mut conn, user_id)
1138 .await
1139 .map_err(|e| {
1140 ControllerError::new(
1141 ControllerErrorType::InternalServerError,
1142 "Failed to get user".to_string(),
1143 Some(anyhow!(e)),
1144 )
1145 })?;
1146
1147 authorization::remember(&session, user)?;
1148
1149 let skip_token = skip_authorize();
1150 skip_token.authorized_ok(web::Json(true))
1151}
1152
1153#[derive(OpenApi)]
1154#[openapi(
1155 paths(
1156 signup,
1157 login,
1158 logout,
1159 logged_in,
1160 authorize_action_on_resource,
1161 authorize_multiple_actions_on_resources,
1162 user_info,
1163 send_delete_user_email_code,
1164 delete_user_account,
1165 verify_email,
1166 ),
1167 components(schemas(
1168 Login,
1169 LoginResponse,
1170 CreateAccountDetails,
1171 SignupResponse,
1172 UserInfo,
1173 crate::domain::authorization::ActionOnResource,
1174 crate::domain::authorization::Action,
1175 crate::domain::authorization::Resource,
1176 SendEmailCodeData,
1177 EmailCode,
1178 VerifyEmailRequest,
1179 headless_lms_models::roles::UserRole,
1180 ))
1181)]
1182pub struct AuthRoutesApiDoc;
1183
1184pub fn _add_routes(cfg: &mut ServiceConfig) {
1185 cfg.service(
1186 web::resource("/signup")
1187 .wrap(RateLimit::new(RateLimitConfig {
1188 per_minute: Some(15),
1189 per_hour: None,
1190 per_day: Some(1000),
1191 per_month: None,
1192 ..Default::default()
1193 }))
1194 .to(signup),
1195 )
1196 .service(
1197 web::resource("/login")
1198 .wrap(RateLimit::new(RateLimitConfig {
1199 per_minute: Some(20),
1200 per_hour: Some(100),
1201 per_day: Some(500),
1202 per_month: None,
1203 ..Default::default()
1204 }))
1205 .to(login),
1206 )
1207 .route("/logout", web::post().to(logout))
1208 .route("/logged-in", web::get().to(logged_in))
1209 .route("/authorize", web::post().to(authorize_action_on_resource))
1210 .route(
1211 "/authorize-multiple",
1212 web::post().to(authorize_multiple_actions_on_resources),
1213 )
1214 .route("/user-info", web::get().to(user_info))
1215 .service(
1216 web::resource("/delete-user-account")
1217 .wrap(RateLimit::new(RateLimitConfig {
1218 per_minute: None,
1219 per_hour: Some(5),
1220 per_day: Some(10),
1221 per_month: None,
1222 ..Default::default()
1223 }))
1224 .to(delete_user_account),
1225 )
1226 .service(
1227 web::resource("/send-email-code")
1228 .wrap(RateLimit::new(RateLimitConfig {
1229 per_minute: None,
1230 per_hour: Some(5),
1231 per_day: Some(20),
1232 per_month: None,
1233 ..Default::default()
1234 }))
1235 .to(send_delete_user_email_code),
1236 )
1237 .service(
1238 web::resource("/verify-email")
1239 .wrap(RateLimit::new(RateLimitConfig {
1240 per_minute: Some(10),
1241 per_hour: Some(50),
1242 per_day: None,
1243 per_month: None,
1244 ..Default::default()
1245 }))
1246 .to(verify_email),
1247 );
1248}