1use crate::domain::exercise_services::token::delete_user_and_invalidate_cached_tokens;
6use crate::{
7 OAuthClient,
8 domain::{
9 authentication,
10 authorization::{ActionOnResource, is_permitted, is_user_global_admin, skip_authorize},
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;
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 cache::Cache,
25 prelude::UtilErrorType,
26 services::tmc::{NewUserInfo, TmcClient},
27};
28use secrecy::{ExposeSecret, SecretString};
29use tracing_log::log;
30use utoipa::{OpenApi, ToSchema};
31
32#[derive(Debug, Deserialize, ToSchema)]
33pub struct Login {
34 pub email: String,
35 #[schema(value_type = String)]
36 pub password: SecretString,
37}
38
39#[derive(Debug, Serialize, Deserialize, ToSchema)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum LoginResponse {
42 Success,
43 RequiresEmailVerification {
44 #[schema(value_type = String)]
45 email_verification_token: OutboundSecret,
46 },
47 Failed,
48}
49
50#[derive(Debug, Serialize, Deserialize, ToSchema)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum SignupResponse {
53 Success,
54 EmailAlreadyExists,
55}
56
57#[utoipa::path(
62 post,
63 path = "/authorize",
64 tag = "auth",
65 operation_id = "postAuthAuthorize",
66 request_body = ActionOnResource,
67 responses(
68 (status = 200, description = "Whether the action is allowed for the current user", body = bool)
69 )
70)]
71#[instrument(skip(pool, payload,))]
72pub async fn authorize_action_on_resource(
73 pool: web::Data<PgPool>,
74 user: Option<AuthUser>,
75 payload: web::Json<ActionOnResource>,
76) -> ControllerResult<web::Json<bool>> {
77 let mut conn = pool.acquire().await?;
78 let data = payload.0;
79 if let Some(user) = user {
80 match authorize(&mut conn, data.action, Some(user.id), data.resource).await {
81 Ok(true_token) => true_token.authorized_ok(web::Json(true)),
82 _ => {
83 let false_token = skip_authorize();
85 false_token.authorized_ok(web::Json(false))
86 }
87 }
88 } else {
89 let false_token = skip_authorize();
91 false_token.authorized_ok(web::Json(false))
92 }
93}
94
95#[derive(Debug, Deserialize, ToSchema)]
96pub struct CreateAccountDetails {
97 pub email: String,
98 pub first_name: String,
99 pub last_name: String,
100 pub language: String,
101 #[schema(value_type = String)]
102 pub password: SecretString,
103 #[schema(value_type = String)]
104 pub password_confirmation: SecretString,
105 pub country: String,
106 pub email_communication_consent: bool,
107}
108
109#[utoipa::path(
130 post,
131 path = "/signup",
132 tag = "auth",
133 operation_id = "postAuthSignup",
134 request_body = CreateAccountDetails,
135 responses(
136 (status = 200, description = "Signup outcome", body = SignupResponse),
137 (status = 400, description = "Cannot sign up (e.g. already signed in or validation error)")
138 )
139)]
140#[instrument(skip(session, pool, payload, app_conf))]
141pub async fn signup(
142 session: Session,
143 payload: web::Json<CreateAccountDetails>,
144 pool: web::Data<PgPool>,
145 user: Option<AuthUser>,
146 app_conf: web::Data<ApplicationConfiguration>,
147 tmc_client: web::Data<TmcClient>,
148) -> ControllerResult<web::Json<SignupResponse>> {
149 let user_details = payload.0;
150 let mut conn = pool.acquire().await?;
151
152 if app_conf.test_mode {
153 return handle_test_mode_signup(&mut conn, &session, &user_details, &app_conf).await;
154 }
155 if user.is_none() {
156 match models::users::get_by_email(&mut conn, &user_details.email).await {
157 Ok(_) => {
158 let token = skip_authorize();
159 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
160 }
161 Err(error)
162 if matches!(
163 error.error_type(),
164 ModelErrorType::RecordNotFound | ModelErrorType::NotFound
165 ) => {}
166 Err(error) => return Err(error.into()),
167 }
168
169 let upstream_id = match tmc_client
170 .post_new_user_to_tmc(
171 NewUserInfo {
172 first_name: user_details.first_name.clone(),
173 last_name: user_details.last_name.clone(),
174 email: user_details.email.clone(),
175 password: user_details.password.clone(),
176 password_confirmation: user_details.password_confirmation.clone(),
177 language: user_details.language.clone(),
178 },
179 app_conf.as_ref(),
180 )
181 .await
182 {
183 Ok(upstream_id) => upstream_id,
184 Err(error) => {
185 let error_message = error.message().to_string();
186 if matches!(error.error_type(), &UtilErrorType::TmcErrorResponse)
187 && is_duplicate_email_error_message(&error_message)
188 {
189 let token = skip_authorize();
190 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
191 }
192 return match error.error_type() {
193 UtilErrorType::TmcErrorResponse => {
194 Err(controller_err!(BadRequest, error_message, anyhow!(error)))
195 }
196 UtilErrorType::TmcHttpError => Err(controller_err!(
197 InternalServerError,
198 error_message,
199 anyhow!(error)
200 )),
201 _ => Err(controller_err!(
202 InternalServerError,
203 error_message,
204 anyhow!(error)
205 )),
206 };
207 }
208 };
209 let password_secret = user_details.password;
210
211 let user = models::users::insert_with_upstream_id_and_moocfi_id(
212 &mut conn,
213 &user_details.email,
214 Some(&user_details.first_name),
215 Some(&user_details.last_name),
216 upstream_id,
217 PKeyPolicy::Generate.into_uuid(),
218 )
219 .await;
220 let user = match user {
221 Ok(user) => user,
222 Err(error)
223 if matches!(
224 error.error_type(),
225 ModelErrorType::DatabaseConstraint { constraint, .. }
226 if constraint == "users_email"
227 ) =>
228 {
229 let token = skip_authorize();
230 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
231 }
232 Err(error)
236 if matches!(
237 error.error_type(),
238 ModelErrorType::DatabaseConstraint { constraint, .. }
239 if constraint == "users_upstream_id_active_uniq_idx"
240 ) =>
241 {
242 models::users::find_by_upstream_id(&mut conn, upstream_id)
243 .await?
244 .ok_or(error)?
245 }
246 Err(error) => {
247 return Err(controller_err!(
248 InternalServerError,
249 "Failed to insert user.".to_string(),
250 anyhow!(error)
251 ));
252 }
253 };
254
255 let country = user_details.country.clone();
256 models::user_details::update_user_country(&mut conn, user.id, &country).await?;
257 models::user_details::update_user_email_communication_consent(
258 &mut conn,
259 user.id,
260 user_details.email_communication_consent,
261 )
262 .await?;
263
264 let password_hash = models::user_passwords::hash_password(&password_secret)
266 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
267
268 models::user_passwords::upsert_user_password(&mut conn, user.id, &password_hash)
269 .await
270 .map_err(|e| {
271 ControllerError::new(
272 ControllerErrorType::InternalServerError,
273 "Failed to add password to database".to_string(),
274 anyhow!(e),
275 )
276 })?;
277
278 crate::controllers::tmc_server::notify_password_managed_with_retry(
282 &tmc_client,
283 upstream_id.to_string(),
284 user.id,
285 )
286 .await;
287
288 domain::email_ownership_verification::queue_verification_email_best_effort(
290 &mut conn,
291 app_conf.enable_email_ownership_verification,
292 user.id,
293 )
294 .await;
295
296 let token = skip_authorize();
297 authentication::remember(&session, user)?;
298 token.authorized_ok(web::Json(SignupResponse::Success))
299 } else {
300 Err(ControllerError::new(
301 ControllerErrorType::BadRequest,
302 "Cannot create a new account when signed in.".to_string(),
303 None,
304 ))
305 }
306}
307
308async fn handle_test_mode_signup(
309 conn: &mut PgConnection,
310 session: &Session,
311 user_details: &CreateAccountDetails,
312 app_conf: &ApplicationConfiguration,
313) -> ControllerResult<web::Json<SignupResponse>> {
314 assert!(
315 app_conf.test_mode,
316 "handle_test_mode_signup called outside test mode"
317 );
318
319 warn!("Handling signup in test mode. No real account is created.");
320
321 match models::users::get_by_email(conn, &user_details.email).await {
322 Ok(_) => {
323 let token = skip_authorize();
324 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
325 }
326 Err(error)
327 if matches!(
328 error.error_type(),
329 ModelErrorType::RecordNotFound | ModelErrorType::NotFound
330 ) => {}
331 Err(error) => return Err(error.into()),
332 }
333
334 let user_id = models::users::insert(
335 conn,
336 PKeyPolicy::Generate,
337 &user_details.email,
338 Some(&user_details.first_name),
339 Some(&user_details.last_name),
340 )
341 .await;
342 let user_id = match user_id {
343 Ok(user_id) => user_id,
344 Err(error) => match error.error_type() {
345 ModelErrorType::DatabaseConstraint { constraint, .. }
346 if constraint == "users_email" =>
347 {
348 let token = skip_authorize();
349 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
350 }
351 _ => {
352 return Err(controller_err!(
353 InternalServerError,
354 "Failed to insert test user.".to_string(),
355 anyhow!(error)
356 ));
357 }
358 },
359 };
360
361 models::user_details::update_user_country(conn, user_id, &user_details.country).await?;
362 models::user_details::update_user_email_communication_consent(
363 conn,
364 user_id,
365 user_details.email_communication_consent,
366 )
367 .await?;
368
369 let user = models::users::get_by_email(conn, &user_details.email).await?;
370
371 let password_hash = models::user_passwords::hash_password(&user_details.password)
372 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
373
374 models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
375 .await
376 .map_err(|e| {
377 ControllerError::new(
378 ControllerErrorType::InternalServerError,
379 "Failed to add password to database".to_string(),
380 anyhow!(e),
381 )
382 })?;
383 domain::email_ownership_verification::queue_verification_email_best_effort(
384 conn,
385 app_conf.enable_email_ownership_verification,
386 user.id,
387 )
388 .await;
389
390 authentication::remember(session, user)?;
391
392 let token = skip_authorize();
393 token.authorized_ok(web::Json(SignupResponse::Success))
394}
395
396fn is_duplicate_email_error_message(message: &str) -> bool {
397 let normalized = message.to_lowercase();
398 normalized.contains("email already exists")
399 || normalized.contains("email is already registered")
400 || normalized.contains("email already in use")
401 || normalized.contains("duplicate email")
402 || normalized.contains("unique constraint")
403 || normalized.contains("duplicate key")
404 || normalized.contains("users_email")
405 || normalized.contains("email_key")
406}
407
408#[utoipa::path(
414 post,
415 path = "/authorize-multiple",
416 tag = "auth",
417 operation_id = "postAuthAuthorizeMultiple",
418 request_body = Vec<ActionOnResource>,
419 responses(
420 (status = 200, description = "Authorization result for each input action, in order", body = Vec<bool>)
421 )
422)]
423#[instrument(skip(pool, payload,))]
424pub async fn authorize_multiple_actions_on_resources(
425 pool: web::Data<PgPool>,
426 user: Option<AuthUser>,
427 payload: web::Json<Vec<ActionOnResource>>,
428) -> ControllerResult<web::Json<Vec<bool>>> {
429 let mut conn = pool.acquire().await?;
430 let input = payload.into_inner();
431 let mut results = Vec::with_capacity(input.len());
432 if let Some(user) = user {
433 let user_roles = models::roles::get_roles(&mut conn, user.id).await?;
435
436 for action_on_resource in input {
437 if is_permitted(
438 &mut conn,
439 action_on_resource.action,
440 action_on_resource.resource,
441 &user_roles,
442 )
443 .await
444 .unwrap_or(false)
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 authentication::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 authentication::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 authentication::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 = authentication::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 authentication::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 authentication::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 = authentication::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, cache, app_conf))]
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 app_conf: web::Data<ApplicationConfiguration>,
883 cache: web::Data<Cache>,
884) -> ControllerResult<web::Json<bool>> {
885 let token = skip_authorize();
886 if let Some(auth_user) = auth_user {
887 let mut conn = pool.acquire().await?;
888
889 let code_ok = user_email_codes::is_reset_user_email_code_valid(
891 &mut conn,
892 auth_user.id,
893 UserEmailCodePurpose::AccountDeletion,
894 &payload.code,
895 )
896 .await?;
897
898 if !code_ok {
899 info!(
900 "User {} attempted account deletion with incorrect code",
901 auth_user.id
902 );
903 return token.authorized_ok(web::Json(false));
904 }
905
906 let mut tx = conn.begin().await?;
907 let user = users::get_by_id(&mut tx, auth_user.id).await?;
908
909 if let Some(upstream_id) = user.upstream_id {
911 let upstream_id_str = upstream_id.to_string();
912 let tmc_success = tmc_client
913 .delete_user_from_tmc(upstream_id_str)
914 .await
915 .unwrap_or(false);
916
917 if !tmc_success {
918 info!("TMC deletion failed for user {}", auth_user.id);
919 return token.authorized_ok(web::Json(false));
920 }
921 }
922
923 delete_user_and_invalidate_cached_tokens(
925 &mut tx,
926 &cache,
927 &app_conf.oauth_server_configuration.oauth_token_hmac_key,
928 auth_user.id,
929 )
930 .await?;
931 user_email_codes::mark_user_email_code_used(
932 &mut tx,
933 auth_user.id,
934 UserEmailCodePurpose::AccountDeletion,
935 &payload.code,
936 )
937 .await?;
938
939 tx.commit().await?;
940 authentication::forget(&session);
941 token.authorized_ok(web::Json(true))
942 } else {
943 return token.authorized_ok(web::Json(false));
944 }
945}
946
947pub async fn update_user_information_to_tmc(
948 first_name: String,
949 last_name: String,
950 email: Option<String>,
951 user_upstream_id: String,
952 tmc_client: web::Data<TmcClient>,
953 app_conf: web::Data<ApplicationConfiguration>,
954) -> Result<(), Error> {
955 if app_conf.test_mode {
956 return Ok(());
957 }
958 tmc_client
959 .update_user_information(first_name, last_name, email, user_upstream_id)
960 .await
961 .map_err(|e| {
962 log::warn!("TMC user update failed: {:?}", e);
963 anyhow::anyhow!("TMC user update failed: {}", e)
964 })?;
965 Ok(())
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 authentication::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 headless_lms_authorization::ActionOnResource,
1174 headless_lms_authorization::Action,
1175 headless_lms_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}