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 email_ownership_verification::{MAX_CODE_ATTEMPTS, MIN_RESEND_INTERVAL_MINUTES},
12 rate_limit_middleware_builder::{RateLimit, RateLimitConfig},
13 },
14 prelude::*,
15};
16use actix_session::Session;
17use anyhow::Error;
18use anyhow::anyhow;
19use chrono::Duration;
20use headless_lms_models::ModelErrorType;
21use headless_lms_models::{
22 email_templates::EmailTemplateType, email_verification_tokens, user_email_codes,
23 user_email_codes::UserEmailCodePurpose, user_passwords, users,
24};
25use headless_lms_utils::{
26 cache::Cache,
27 prelude::{UtilError, UtilErrorType},
28 services::tmc::{NewUserInfo, TmcAccountDeletion, TmcClient},
29};
30use secrecy::{ExposeSecret, SecretString};
31use tracing_log::log;
32use utoipa::{OpenApi, ToSchema};
33
34#[derive(Debug, Deserialize, ToSchema)]
35pub struct Login {
36 pub email: String,
37 #[schema(value_type = String)]
38 pub password: SecretString,
39}
40
41#[derive(Debug, Serialize, Deserialize, ToSchema)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum LoginResponse {
44 Success,
45 RequiresEmailVerification {
46 #[schema(value_type = String)]
47 email_verification_token: OutboundSecret,
48 },
49 Failed,
50}
51
52#[derive(Debug, Serialize, Deserialize, ToSchema)]
53#[serde(tag = "type", rename_all = "snake_case")]
54pub enum SignupResponse {
55 Success,
56 EmailAlreadyExists,
57}
58
59#[utoipa::path(
64 post,
65 path = "/authorize",
66 tag = "auth",
67 operation_id = "postAuthAuthorize",
68 request_body = ActionOnResource,
69 responses(
70 (status = 200, description = "Whether the action is allowed for the current user", body = bool)
71 )
72)]
73#[instrument(skip(pool, payload,))]
74pub async fn authorize_action_on_resource(
75 pool: web::Data<PgPool>,
76 user: Option<AuthUser>,
77 payload: web::Json<ActionOnResource>,
78) -> ControllerResult<web::Json<bool>> {
79 let mut conn = pool.acquire().await?;
80 let data = payload.0;
81 if let Some(user) = user {
82 match authorize(&mut conn, data.action, Some(user.id), data.resource).await {
83 Ok(true_token) => true_token.authorized_ok(web::Json(true)),
84 _ => {
85 let false_token = skip_authorize();
87 false_token.authorized_ok(web::Json(false))
88 }
89 }
90 } else {
91 let false_token = skip_authorize();
93 false_token.authorized_ok(web::Json(false))
94 }
95}
96
97#[derive(Debug, Deserialize, ToSchema)]
98pub struct CreateAccountDetails {
99 pub email: String,
100 pub first_name: String,
101 pub last_name: String,
102 pub language: String,
103 #[schema(value_type = String)]
104 pub password: SecretString,
105 #[schema(value_type = String)]
106 pub password_confirmation: SecretString,
107 pub country: String,
108 pub email_communication_consent: bool,
109}
110
111#[utoipa::path(
132 post,
133 path = "/signup",
134 tag = "auth",
135 operation_id = "postAuthSignup",
136 request_body = CreateAccountDetails,
137 responses(
138 (status = 200, description = "Signup outcome", body = SignupResponse),
139 (status = 400, description = "Cannot sign up (e.g. already signed in or validation error)")
140 )
141)]
142#[instrument(skip(session, pool, payload, app_conf))]
143pub async fn signup(
144 session: Session,
145 payload: web::Json<CreateAccountDetails>,
146 pool: web::Data<PgPool>,
147 user: Option<AuthUser>,
148 app_conf: web::Data<ApplicationConfiguration>,
149 tmc_client: web::Data<TmcClient>,
150) -> ControllerResult<web::Json<SignupResponse>> {
151 let user_details = payload.0;
152 let mut conn = pool.acquire().await?;
153
154 if app_conf.test_mode {
155 return handle_test_mode_signup(&mut conn, &session, &user_details, &app_conf).await;
156 }
157 if user.is_none() {
158 match models::users::get_by_email(&mut conn, &user_details.email).await {
159 Ok(_) => {
160 let token = skip_authorize();
161 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
162 }
163 Err(error)
164 if matches!(
165 error.error_type(),
166 ModelErrorType::RecordNotFound | ModelErrorType::NotFound
167 ) => {}
168 Err(error) => return Err(error.into()),
169 }
170
171 let upstream_id = match tmc_client
172 .post_new_user_to_tmc(
173 NewUserInfo {
174 first_name: user_details.first_name.clone(),
175 last_name: user_details.last_name.clone(),
176 email: user_details.email.clone(),
177 password: user_details.password.clone(),
178 password_confirmation: user_details.password_confirmation.clone(),
179 language: user_details.language.clone(),
180 },
181 app_conf.as_ref(),
182 )
183 .await
184 {
185 Ok(upstream_id) => upstream_id,
186 Err(error) => {
187 let error_message = error.message().to_string();
188 if matches!(error.error_type(), &UtilErrorType::TmcErrorResponse)
189 && is_duplicate_email_error_message(&error_message)
190 {
191 let token = skip_authorize();
192 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
193 }
194 return match error.error_type() {
195 UtilErrorType::TmcErrorResponse => {
196 Err(controller_err!(BadRequest, error_message, anyhow!(error)))
197 }
198 UtilErrorType::TmcHttpError => Err(controller_err!(
199 InternalServerError,
200 error_message,
201 anyhow!(error)
202 )),
203 _ => Err(controller_err!(
204 InternalServerError,
205 error_message,
206 anyhow!(error)
207 )),
208 };
209 }
210 };
211 let password_secret = user_details.password;
212
213 let user = models::users::insert_with_upstream_id_and_moocfi_id(
214 &mut conn,
215 &user_details.email,
216 Some(&user_details.first_name),
217 Some(&user_details.last_name),
218 upstream_id,
219 PKeyPolicy::Generate.into_uuid(),
220 )
221 .await;
222 let user = match user {
223 Ok(user) => user,
224 Err(error)
225 if matches!(
226 error.error_type(),
227 ModelErrorType::DatabaseConstraint { constraint, .. }
228 if constraint == "users_email"
229 ) =>
230 {
231 let token = skip_authorize();
232 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
233 }
234 Err(error)
238 if matches!(
239 error.error_type(),
240 ModelErrorType::DatabaseConstraint { constraint, .. }
241 if constraint == "users_upstream_id_active_uniq_idx"
242 ) =>
243 {
244 models::users::find_by_upstream_id(&mut conn, upstream_id)
245 .await?
246 .ok_or(error)?
247 }
248 Err(error) => {
249 return Err(controller_err!(
250 InternalServerError,
251 "Failed to insert user.".to_string(),
252 anyhow!(error)
253 ));
254 }
255 };
256
257 let country = user_details.country.clone();
258 models::user_details::update_user_country(&mut conn, user.id, &country).await?;
259 models::user_details::update_user_email_communication_consent(
260 &mut conn,
261 user.id,
262 user_details.email_communication_consent,
263 )
264 .await?;
265
266 let password_hash = models::user_passwords::hash_password(&password_secret)
268 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
269
270 models::user_passwords::upsert_user_password(&mut conn, user.id, &password_hash)
271 .await
272 .map_err(|e| {
273 ControllerError::new(
274 ControllerErrorType::InternalServerError,
275 "Failed to add password to database".to_string(),
276 anyhow!(e),
277 )
278 })?;
279
280 crate::controllers::tmc_server::notify_password_managed_with_retry(
284 &tmc_client,
285 upstream_id.to_string(),
286 user.id,
287 )
288 .await;
289
290 domain::email_ownership_verification::queue_verification_email_best_effort(
292 &mut conn,
293 app_conf.enable_email_ownership_verification,
294 user.id,
295 )
296 .await;
297
298 let token = skip_authorize();
299 authentication::remember(&session, user)?;
300 token.authorized_ok(web::Json(SignupResponse::Success))
301 } else {
302 Err(ControllerError::new(
303 ControllerErrorType::BadRequest,
304 "Cannot create a new account when signed in.".to_string(),
305 None,
306 ))
307 }
308}
309
310async fn handle_test_mode_signup(
311 conn: &mut PgConnection,
312 session: &Session,
313 user_details: &CreateAccountDetails,
314 app_conf: &ApplicationConfiguration,
315) -> ControllerResult<web::Json<SignupResponse>> {
316 assert!(
317 app_conf.test_mode,
318 "handle_test_mode_signup called outside test mode"
319 );
320
321 warn!("Handling signup in test mode. No real account is created.");
322
323 match models::users::get_by_email(conn, &user_details.email).await {
324 Ok(_) => {
325 let token = skip_authorize();
326 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
327 }
328 Err(error)
329 if matches!(
330 error.error_type(),
331 ModelErrorType::RecordNotFound | ModelErrorType::NotFound
332 ) => {}
333 Err(error) => return Err(error.into()),
334 }
335
336 let user_id = models::users::insert(
337 conn,
338 PKeyPolicy::Generate,
339 &user_details.email,
340 Some(&user_details.first_name),
341 Some(&user_details.last_name),
342 )
343 .await;
344 let user_id = match user_id {
345 Ok(user_id) => user_id,
346 Err(error) => match error.error_type() {
347 ModelErrorType::DatabaseConstraint { constraint, .. }
348 if constraint == "users_email" =>
349 {
350 let token = skip_authorize();
351 return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
352 }
353 _ => {
354 return Err(controller_err!(
355 InternalServerError,
356 "Failed to insert test user.".to_string(),
357 anyhow!(error)
358 ));
359 }
360 },
361 };
362
363 models::user_details::update_user_country(conn, user_id, &user_details.country).await?;
364 models::user_details::update_user_email_communication_consent(
365 conn,
366 user_id,
367 user_details.email_communication_consent,
368 )
369 .await?;
370
371 let user = models::users::get_by_email(conn, &user_details.email).await?;
372
373 let password_hash = models::user_passwords::hash_password(&user_details.password)
374 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
375
376 models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
377 .await
378 .map_err(|e| {
379 ControllerError::new(
380 ControllerErrorType::InternalServerError,
381 "Failed to add password to database".to_string(),
382 anyhow!(e),
383 )
384 })?;
385 domain::email_ownership_verification::queue_verification_email_best_effort(
386 conn,
387 app_conf.enable_email_ownership_verification,
388 user.id,
389 )
390 .await;
391
392 authentication::remember(session, user)?;
393
394 let token = skip_authorize();
395 token.authorized_ok(web::Json(SignupResponse::Success))
396}
397
398fn is_duplicate_email_error_message(message: &str) -> bool {
399 let normalized = message.to_lowercase();
400 normalized.contains("email already exists")
401 || normalized.contains("email is already registered")
402 || normalized.contains("email already in use")
403 || normalized.contains("duplicate email")
404 || normalized.contains("unique constraint")
405 || normalized.contains("duplicate key")
406 || normalized.contains("users_email")
407 || normalized.contains("email_key")
408}
409
410#[utoipa::path(
416 post,
417 path = "/authorize-multiple",
418 tag = "auth",
419 operation_id = "postAuthAuthorizeMultiple",
420 request_body = Vec<ActionOnResource>,
421 responses(
422 (status = 200, description = "Authorization result for each input action, in order", body = Vec<bool>)
423 )
424)]
425#[instrument(skip(pool, payload,))]
426pub async fn authorize_multiple_actions_on_resources(
427 pool: web::Data<PgPool>,
428 user: Option<AuthUser>,
429 payload: web::Json<Vec<ActionOnResource>>,
430) -> ControllerResult<web::Json<Vec<bool>>> {
431 let mut conn = pool.acquire().await?;
432 let input = payload.into_inner();
433 let mut results = Vec::with_capacity(input.len());
434 if let Some(user) = user {
435 let user_roles = models::roles::get_roles(&mut conn, user.id).await?;
437
438 for action_on_resource in input {
439 if is_permitted(
440 &mut conn,
441 action_on_resource.action,
442 action_on_resource.resource,
443 &user_roles,
444 )
445 .await
446 .unwrap_or(false)
447 {
448 results.push(true);
449 } else {
450 results.push(false);
451 }
452 }
453 } else {
454 for _action_on_resource in input {
456 results.push(false);
457 }
458 }
459 let token = skip_authorize();
460 token.authorized_ok(web::Json(results))
461}
462
463#[utoipa::path(
468 post,
469 path = "/login",
470 tag = "auth",
471 operation_id = "postAuthLogin",
472 request_body = Login,
473 responses(
474 (status = 200, description = "Login outcome", body = LoginResponse)
475 )
476)]
477#[instrument(skip(session, pool, client, payload, app_conf, tmc_client))]
478pub async fn login(
479 session: Session,
480 pool: web::Data<PgPool>,
481 client: web::Data<OAuthClient>,
482 app_conf: web::Data<ApplicationConfiguration>,
483 payload: web::Json<Login>,
484 tmc_client: web::Data<TmcClient>,
485) -> ControllerResult<web::Json<LoginResponse>> {
486 let mut conn = pool.acquire().await?;
487 let Login { email, password } = payload.into_inner();
488
489 if app_conf.development_uuid_login {
491 return handle_uuid_login(&session, &mut conn, &email, &app_conf).await;
492 }
493
494 if app_conf.test_mode {
496 return handle_test_mode_login(&session, &mut conn, &email, &password, &app_conf).await;
497 };
498
499 return handle_production_login(
500 &session,
501 &mut conn,
502 &client,
503 &tmc_client,
504 &email,
505 &password,
506 &app_conf,
507 )
508 .await;
509}
510
511async fn handle_uuid_login(
512 session: &Session,
513 conn: &mut PgConnection,
514 email: &str,
515 app_conf: &ApplicationConfiguration,
516) -> ControllerResult<web::Json<LoginResponse>> {
517 warn!("Trying development mode UUID login");
518 let token = skip_authorize();
519
520 if let Ok(id) = Uuid::parse_str(email) {
521 let user = { models::users::get_by_id(conn, id).await? };
522 let is_admin = is_user_global_admin(conn, user.id).await?;
523
524 if app_conf.enable_admin_email_verification && is_admin {
525 return handle_email_verification(conn, &user).await;
526 }
527
528 authentication::remember(session, user)?;
529 token.authorized_ok(web::Json(LoginResponse::Success))
530 } else {
531 warn!("Authentication failed");
532 token.authorized_ok(web::Json(LoginResponse::Failed))
533 }
534}
535
536async fn handle_test_mode_login(
537 session: &Session,
538 conn: &mut PgConnection,
539 email: &str,
540 password: &SecretString,
541 app_conf: &ApplicationConfiguration,
542) -> ControllerResult<web::Json<LoginResponse>> {
543 warn!("Using test credentials. Normal accounts won't work.");
544
545 let user = match models::users::get_by_email(conn, email).await {
546 Ok(u) => u,
547 Err(_) => {
548 warn!("Test user not found for {}", email);
549 let token = skip_authorize();
550 return token.authorized_ok(web::Json(LoginResponse::Failed));
551 }
552 };
553
554 let mut is_authenticated =
555 authentication::authenticate_test_user(conn, email, password, app_conf)
556 .await
557 .map_err(|e| {
558 ControllerError::new(
559 ControllerErrorType::Unauthorized,
560 "Could not find the test user. Have you seeded the database?".to_string(),
561 e,
562 )
563 })?;
564
565 if !is_authenticated {
566 is_authenticated =
567 models::user_passwords::verify_user_password(conn, user.id, password).await?;
568 }
569
570 if is_authenticated {
571 info!("Authentication successful");
572 let is_admin = is_user_global_admin(conn, user.id).await?;
573
574 if app_conf.enable_admin_email_verification && is_admin {
575 return handle_email_verification(conn, &user).await;
576 }
577
578 authentication::remember(session, user)?;
579 } else {
580 warn!("Authentication failed");
581 }
582
583 let token = skip_authorize();
584 if is_authenticated {
585 token.authorized_ok(web::Json(LoginResponse::Success))
586 } else {
587 token.authorized_ok(web::Json(LoginResponse::Failed))
588 }
589}
590
591async fn handle_production_login(
592 session: &Session,
593 conn: &mut PgConnection,
594 client: &OAuthClient,
595 tmc_client: &TmcClient,
596 email: &str,
597 password: &SecretString,
598 app_conf: &ApplicationConfiguration,
599) -> ControllerResult<web::Json<LoginResponse>> {
600 let email = email.trim();
603 let mut is_authenticated = false;
604 let mut authenticated_user: Option<headless_lms_models::users::User> = None;
605
606 if let Ok(user) = models::users::get_by_email(conn, email).await {
608 let is_password_stored =
609 models::user_passwords::check_if_users_password_is_stored(conn, user.id).await?;
610 if is_password_stored {
611 is_authenticated =
612 models::user_passwords::verify_user_password(conn, user.id, password).await?;
613
614 if is_authenticated {
615 info!("Authentication successful");
616 authenticated_user = Some(user);
617 }
618 }
619 }
620
621 if !is_authenticated {
623 let auth_result = authentication::authenticate_tmc_mooc_fi_user(
624 conn,
625 client,
626 email.to_string(),
627 password.clone(),
628 tmc_client,
629 )
630 .await?;
631
632 if let Some((user, _token)) = auth_result {
633 let password_hash = models::user_passwords::hash_password(password)
635 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
636
637 models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
638 .await
639 .map_err(|e| {
640 ControllerError::new(
641 ControllerErrorType::InternalServerError,
642 "Failed to add password to database".to_string(),
643 anyhow!(e),
644 )
645 })?;
646
647 if let Some(upstream_id) = user.upstream_id {
651 crate::controllers::tmc_server::notify_password_managed_with_retry(
652 tmc_client,
653 upstream_id.to_string(),
654 user.id,
655 )
656 .await;
657 } else {
658 warn!("User has no upstream_id; skipping notify to TMC");
659 }
660 info!("Authentication successful");
661 authenticated_user = Some(user);
662 is_authenticated = true;
663 }
664 }
665
666 let token = skip_authorize();
667 if is_authenticated {
668 if let Some(user) = authenticated_user {
669 let is_admin = is_user_global_admin(conn, user.id).await?;
670
671 if app_conf.enable_admin_email_verification && is_admin {
672 return handle_email_verification(conn, &user).await;
673 }
674
675 authentication::remember(session, user)?;
676 }
677 token.authorized_ok(web::Json(LoginResponse::Success))
678 } else {
679 warn!("Authentication failed");
680 token.authorized_ok(web::Json(LoginResponse::Failed))
681 }
682}
683
684#[utoipa::path(
688 post,
689 path = "/logout",
690 tag = "auth",
691 operation_id = "postAuthLogout",
692 responses((status = 200, description = "Session cleared"))
693)]
694#[instrument(skip(session))]
695#[allow(clippy::async_yields_async)]
696pub async fn logout(session: Session) -> HttpResponse {
697 authentication::forget(&session);
698 HttpResponse::Ok().finish()
699}
700
701#[utoipa::path(
705 get,
706 path = "/logged-in",
707 tag = "auth",
708 operation_id = "getAuthLoggedIn",
709 responses(
710 (status = 200, description = "True when an authenticated session exists", body = bool)
711 )
712)]
713#[instrument(skip(session))]
714pub async fn logged_in(session: Session, pool: web::Data<PgPool>) -> web::Json<bool> {
715 let logged_in = authentication::has_auth_user_session(&session, pool).await;
716 web::Json(logged_in)
717}
718
719#[derive(Debug, Serialize, Deserialize, ToSchema)]
723
724pub struct UserInfo {
725 pub user_id: Uuid,
726 pub first_name: Option<String>,
727 pub last_name: Option<String>,
728}
729
730#[utoipa::path(
735 get,
736 path = "/user-info",
737 tag = "auth",
738 operation_id = "getAuthUserInfo",
739 responses(
740 (status = 200, description = "Profile when signed in; null when anonymous", body = Option<UserInfo>)
741 )
742)]
743#[instrument(skip(auth_user, pool))]
744pub async fn user_info(
745 auth_user: Option<AuthUser>,
746 pool: web::Data<PgPool>,
747) -> ControllerResult<web::Json<Option<UserInfo>>> {
748 let token = skip_authorize();
749 if let Some(auth_user) = auth_user {
750 let mut conn = pool.acquire().await?;
751 let user_details =
752 models::user_details::get_user_details_by_user_id(&mut conn, auth_user.id).await?;
753
754 token.authorized_ok(web::Json(Some(UserInfo {
755 user_id: user_details.user_id,
756 first_name: user_details.first_name,
757 last_name: user_details.last_name,
758 })))
759 } else {
760 token.authorized_ok(web::Json(None))
761 }
762}
763
764#[derive(Debug, Deserialize, ToSchema)]
765pub struct SendEmailCodeData {
766 #[schema(value_type = String)]
767 pub password: SecretString,
768 pub language: String,
769}
770
771#[derive(Debug, Serialize, Deserialize, ToSchema)]
773#[serde(tag = "type", rename_all = "snake_case")]
774pub enum SendDeleteUserEmailCodeResult {
775 Queued,
776 RecentlySent {
778 retry_after_seconds: i64,
779 },
780 IncorrectPassword,
781}
782
783#[utoipa::path(
787 post,
788 path = "/send-email-code",
789 tag = "auth",
790 operation_id = "postAuthSendEmailCode",
791 request_body = SendEmailCodeData,
792 responses(
793 (status = 200, description = "What the request did", body = SendDeleteUserEmailCodeResult)
794 )
795)]
796#[instrument(skip(pool, payload, auth_user))]
797pub async fn send_delete_user_email_code(
798 auth_user: AuthUser,
799 pool: web::Data<PgPool>,
800 payload: web::Json<SendEmailCodeData>,
801) -> ControllerResult<web::Json<SendDeleteUserEmailCodeResult>> {
802 let token = skip_authorize();
803 let mut conn = pool.acquire().await?;
804
805 let password_ok =
806 user_passwords::verify_user_password(&mut conn, auth_user.id, &payload.password).await?;
807
808 if !password_ok {
809 info!(
810 "User {} attempted account deletion with incorrect password",
811 auth_user.id
812 );
813 return token.authorized_ok(web::Json(SendDeleteUserEmailCodeResult::IncorrectPassword));
814 }
815
816 let live_code = user_email_codes::get_unused_user_email_code_with_user_id(
817 &mut conn,
818 auth_user.id,
819 UserEmailCodePurpose::AccountDeletion,
820 )
821 .await?;
822 if let Some(live_code) = live_code {
823 let retry_after_seconds =
824 (live_code.created_at + Duration::minutes(MIN_RESEND_INTERVAL_MINUTES) - Utc::now())
825 .num_seconds();
826 if retry_after_seconds > 0 {
827 return token.authorized_ok(web::Json(SendDeleteUserEmailCodeResult::RecentlySent {
828 retry_after_seconds,
829 }));
830 }
831 }
832
833 let language = &payload.language;
834 let delete_template = models::email_templates::get_generic_email_template_by_type_and_language(
835 &mut conn,
836 EmailTemplateType::DeleteUserEmail,
837 language,
838 )
839 .await
840 .map_err(|_e| {
841 anyhow::anyhow!(
842 "Account deletion email template not configured. Missing template 'delete-user-email' for language '{}'",
843 language
844 )
845 })?;
846
847 let code = user_email_codes::generate_code();
850
851 let mut tx = conn.begin().await?;
852 user_email_codes::insert_user_email_code(
853 &mut tx,
854 auth_user.id,
855 UserEmailCodePurpose::AccountDeletion,
856 &code,
857 )
858 .await?;
859 models::email_deliveries::insert_email_delivery(&mut tx, auth_user.id, delete_template.id)
860 .await?;
861 tx.commit().await?;
862
863 token.authorized_ok(web::Json(SendDeleteUserEmailCodeResult::Queued))
864}
865
866#[derive(Debug, Deserialize, ToSchema)]
867
868pub struct EmailCode {
869 #[schema(value_type = String)]
870 pub code: DbSecret,
871}
872
873#[derive(Debug, Serialize, Deserialize, ToSchema)]
875#[serde(tag = "type", rename_all = "snake_case")]
876pub enum DeleteUserAccountResult {
877 Deleted,
878 InvalidCode,
881 ExpiredCode,
884 TooManyAttempts,
886 UpstreamUnavailable,
888 UpstreamRejected {
891 reference: Uuid,
892 },
893}
894
895enum TmcDeletionFailure {
898 AlreadyGone,
900 Transient,
901 Rejected,
902}
903
904fn classify_tmc_deletion_failure(error: &UtilError) -> TmcDeletionFailure {
905 match error.error_type() {
906 UtilErrorType::TmcHttpError => TmcDeletionFailure::Transient,
908 UtilErrorType::TmcHttpStatusError(404) => TmcDeletionFailure::AlreadyGone,
911 UtilErrorType::TmcHttpStatusError(429) => TmcDeletionFailure::Transient,
912 UtilErrorType::TmcHttpStatusError(status) if *status >= 500 => {
913 TmcDeletionFailure::Transient
914 }
915 _ => TmcDeletionFailure::Rejected,
916 }
917}
918
919async fn report_tmc_deletion_rejection(
925 conn: &mut PgConnection,
926 user_id: Uuid,
927 reference: Uuid,
928 error: &UtilError,
929) {
930 let report = models::errors::NewErrorReport {
931 service: "headless-lms".to_string(),
932 error_source: Some(models::errors::ErrorSource::Backend),
933 message: format!(
934 "TMC refused to delete the account of user {user_id} (reference {reference}): {error}"
935 ),
936 stack_trace: Some(format!("{error:?}")),
937 path: None,
938 app_version: None,
939 details: Some(serde_json::json!({
940 "kind": "tmc_account_deletion_rejected",
941 "reference": reference,
942 "user_id": user_id,
943 })),
944 };
945 if let Err(e) = models::errors::insert(conn, Some(user_id), &report).await {
946 warn!("Could not record TMC account deletion rejection {reference}: {e}");
947 }
948}
949
950#[utoipa::path(
954 post,
955 path = "/delete-user-account",
956 tag = "auth",
957 operation_id = "postAuthDeleteUserAccount",
958 request_body = EmailCode,
959 responses(
960 (status = 200, description = "Outcome of submitting the code", body = DeleteUserAccountResult)
961 )
962)]
963#[instrument(skip(pool, payload, auth_user, session, cache, app_conf))]
964pub async fn delete_user_account(
965 auth_user: AuthUser,
966 pool: web::Data<PgPool>,
967 payload: web::Json<EmailCode>,
968 session: Session,
969 tmc_client: web::Data<TmcClient>,
970 app_conf: web::Data<ApplicationConfiguration>,
971 cache: web::Data<Cache>,
972) -> ControllerResult<web::Json<DeleteUserAccountResult>> {
973 let token = skip_authorize();
974 let mut conn = pool.acquire().await?;
975
976 let code_ok = user_email_codes::is_reset_user_email_code_valid(
977 &mut conn,
978 auth_user.id,
979 UserEmailCodePurpose::AccountDeletion,
980 &payload.code,
981 )
982 .await?;
983
984 if !code_ok {
985 let outstanding = user_email_codes::get_outstanding_user_email_code(
986 &mut conn,
987 auth_user.id,
988 UserEmailCodePurpose::AccountDeletion,
989 )
990 .await?;
991 if outstanding.is_some_and(|code| code.expires_at <= Utc::now()) {
994 info!(
995 "User {} attempted account deletion with an expired code",
996 auth_user.id
997 );
998 return token.authorized_ok(web::Json(DeleteUserAccountResult::ExpiredCode));
999 }
1000
1001 info!(
1002 "User {} attempted account deletion with incorrect code",
1003 auth_user.id
1004 );
1005 let code_retired = user_email_codes::record_failed_attempt(
1006 &mut conn,
1007 auth_user.id,
1008 UserEmailCodePurpose::AccountDeletion,
1009 MAX_CODE_ATTEMPTS,
1010 )
1011 .await?;
1012 return token.authorized_ok(web::Json(if code_retired {
1013 DeleteUserAccountResult::TooManyAttempts
1014 } else {
1015 DeleteUserAccountResult::InvalidCode
1016 }));
1017 }
1018
1019 let user = users::get_by_id(&mut conn, auth_user.id).await?;
1020
1021 if let Some(upstream_id) = user.upstream_id
1024 && !app_conf.test_mode
1025 {
1026 match tmc_client
1027 .delete_user_from_tmc(upstream_id.to_string())
1028 .await
1029 {
1030 Ok(TmcAccountDeletion::Deleted) => {}
1031 Ok(TmcAccountDeletion::AlreadyDeleted) => {
1032 info!(
1033 "TMC reported account {upstream_id} of user {} as already deleted; deleting locally anyway",
1034 auth_user.id
1035 );
1036 }
1037 Err(error) => match classify_tmc_deletion_failure(&error) {
1038 TmcDeletionFailure::AlreadyGone => {
1039 info!(
1040 "TMC has no account {upstream_id} for user {}; deleting locally anyway: {error}",
1041 auth_user.id
1042 );
1043 }
1044 TmcDeletionFailure::Transient => {
1045 warn!(
1046 "TMC was unavailable while deleting the account of user {}: {error}",
1047 auth_user.id
1048 );
1049 return token
1050 .authorized_ok(web::Json(DeleteUserAccountResult::UpstreamUnavailable));
1051 }
1052 TmcDeletionFailure::Rejected => {
1053 let reference = Uuid::new_v4();
1054 error!(
1055 "TMC refused to delete the account of user {} (reference {reference}): {error}",
1056 auth_user.id
1057 );
1058 report_tmc_deletion_rejection(&mut conn, auth_user.id, reference, &error).await;
1059 return token.authorized_ok(web::Json(
1060 DeleteUserAccountResult::UpstreamRejected { reference },
1061 ));
1062 }
1063 },
1064 }
1065 }
1066
1067 let mut tx = conn.begin().await?;
1068 delete_user_and_invalidate_cached_tokens(
1069 &mut tx,
1070 &cache,
1071 &app_conf.oauth_server_configuration.oauth_token_hmac_key,
1072 auth_user.id,
1073 )
1074 .await?;
1075 user_email_codes::mark_user_email_code_used(
1076 &mut tx,
1077 auth_user.id,
1078 UserEmailCodePurpose::AccountDeletion,
1079 &payload.code,
1080 )
1081 .await?;
1082 tx.commit().await?;
1083
1084 authentication::forget(&session);
1085 token.authorized_ok(web::Json(DeleteUserAccountResult::Deleted))
1086}
1087
1088pub async fn update_user_information_to_tmc(
1089 first_name: String,
1090 last_name: String,
1091 email: Option<String>,
1092 user_upstream_id: String,
1093 tmc_client: web::Data<TmcClient>,
1094 app_conf: web::Data<ApplicationConfiguration>,
1095) -> Result<(), Error> {
1096 if app_conf.test_mode {
1097 return Ok(());
1098 }
1099 tmc_client
1100 .update_user_information(first_name, last_name, email, user_upstream_id)
1101 .await
1102 .map_err(|e| {
1103 log::warn!("TMC user update failed: {:?}", e);
1104 anyhow::anyhow!("TMC user update failed: {}", e)
1105 })?;
1106 Ok(())
1107}
1108
1109async fn handle_email_verification(
1110 conn: &mut PgConnection,
1111 user: &headless_lms_models::users::User,
1112) -> ControllerResult<web::Json<LoginResponse>> {
1113 let code = user_email_codes::generate_code();
1114
1115 let email_verification_token =
1116 email_verification_tokens::create_email_verification_token(conn, user.id, code.clone())
1117 .await
1118 .map_err(|e| {
1119 ControllerError::new(
1120 ControllerErrorType::InternalServerError,
1121 "Failed to create email verification token".to_string(),
1122 Some(anyhow!(e)),
1123 )
1124 })?;
1125
1126 user_email_codes::insert_user_email_code(
1127 conn,
1128 user.id,
1129 UserEmailCodePurpose::AdminLogin,
1130 &code,
1131 )
1132 .await
1133 .map_err(|e| {
1134 ControllerError::new(
1135 ControllerErrorType::InternalServerError,
1136 "Failed to insert user email code".to_string(),
1137 Some(anyhow!(e)),
1138 )
1139 })?;
1140
1141 let email_template = models::email_templates::get_generic_email_template_by_type_and_language(
1142 conn,
1143 EmailTemplateType::ConfirmEmailCode,
1144 "en",
1145 )
1146 .await
1147 .map_err(|e| {
1148 ControllerError::new(
1149 ControllerErrorType::InternalServerError,
1150 format!("Failed to get email template: {}", e.message()),
1151 Some(anyhow!(e)),
1152 )
1153 })?;
1154
1155 models::email_deliveries::insert_email_delivery(conn, user.id, email_template.id)
1156 .await
1157 .map_err(|e| {
1158 ControllerError::new(
1159 ControllerErrorType::InternalServerError,
1160 "Failed to insert email delivery".to_string(),
1161 Some(anyhow!(e)),
1162 )
1163 })?;
1164
1165 email_verification_tokens::mark_code_sent(conn, &email_verification_token)
1166 .await
1167 .map_err(|e| {
1168 ControllerError::new(
1169 ControllerErrorType::InternalServerError,
1170 "Failed to mark code as sent".to_string(),
1171 Some(anyhow!(e)),
1172 )
1173 })?;
1174
1175 let token = skip_authorize();
1176 token.authorized_ok(web::Json(LoginResponse::RequiresEmailVerification {
1177 email_verification_token: OutboundSecret::new(
1179 email_verification_token.expose_secret().to_string(),
1180 ),
1181 }))
1182}
1183
1184#[derive(Debug, Deserialize, ToSchema)]
1185pub struct VerifyEmailRequest {
1186 #[schema(value_type = String)]
1187 pub email_verification_token: DbSecret,
1188 #[schema(value_type = String)]
1189 pub code: DbSecret,
1190}
1191
1192#[utoipa::path(
1196 post,
1197 path = "/verify-email",
1198 tag = "auth",
1199 operation_id = "postAuthVerifyEmail",
1200 request_body = VerifyEmailRequest,
1201 responses(
1202 (status = 200, description = "Whether verification succeeded", body = bool)
1203 )
1204)]
1205#[instrument(skip(session, pool, payload))]
1206pub async fn verify_email(
1207 session: Session,
1208 pool: web::Data<PgPool>,
1209 payload: web::Json<VerifyEmailRequest>,
1210) -> ControllerResult<web::Json<bool>> {
1211 let mut conn = pool.acquire().await?;
1212 let payload = payload.into_inner();
1213
1214 let token = email_verification_tokens::get_by_email_verification_token(
1215 &mut conn,
1216 &payload.email_verification_token,
1217 )
1218 .await
1219 .map_err(|e| {
1220 ControllerError::new(
1221 ControllerErrorType::InternalServerError,
1222 "Failed to get email verification token".to_string(),
1223 Some(anyhow!(e)),
1224 )
1225 })?;
1226
1227 let Some(token_value) = token else {
1228 let skip_token = skip_authorize();
1229 return skip_token.authorized_ok(web::Json(false));
1230 };
1231
1232 let is_valid = email_verification_tokens::verify_code(
1233 &mut conn,
1234 &payload.email_verification_token,
1235 &payload.code,
1236 )
1237 .await
1238 .map_err(|e| {
1239 ControllerError::new(
1240 ControllerErrorType::InternalServerError,
1241 "Failed to verify code".to_string(),
1242 Some(anyhow!(e)),
1243 )
1244 })?;
1245
1246 if !is_valid {
1247 let skip_token = skip_authorize();
1248 return skip_token.authorized_ok(web::Json(false));
1249 }
1250
1251 let user_id = token_value.user_id;
1252
1253 user_email_codes::mark_user_email_code_used(
1254 &mut conn,
1255 user_id,
1256 UserEmailCodePurpose::AdminLogin,
1257 &payload.code,
1258 )
1259 .await
1260 .map_err(|e| {
1261 ControllerError::new(
1262 ControllerErrorType::InternalServerError,
1263 "Failed to mark user email code as used".to_string(),
1264 Some(anyhow!(e)),
1265 )
1266 })?;
1267
1268 email_verification_tokens::mark_as_used(&mut conn, &payload.email_verification_token)
1269 .await
1270 .map_err(|e| {
1271 ControllerError::new(
1272 ControllerErrorType::InternalServerError,
1273 "Failed to mark token as used".to_string(),
1274 Some(anyhow!(e)),
1275 )
1276 })?;
1277
1278 let user = models::users::get_by_id(&mut conn, user_id)
1279 .await
1280 .map_err(|e| {
1281 ControllerError::new(
1282 ControllerErrorType::InternalServerError,
1283 "Failed to get user".to_string(),
1284 Some(anyhow!(e)),
1285 )
1286 })?;
1287
1288 authentication::remember(&session, user)?;
1289
1290 let skip_token = skip_authorize();
1291 skip_token.authorized_ok(web::Json(true))
1292}
1293
1294#[derive(OpenApi)]
1295#[openapi(
1296 paths(
1297 signup,
1298 login,
1299 logout,
1300 logged_in,
1301 authorize_action_on_resource,
1302 authorize_multiple_actions_on_resources,
1303 user_info,
1304 send_delete_user_email_code,
1305 delete_user_account,
1306 verify_email,
1307 ),
1308 components(schemas(
1309 Login,
1310 LoginResponse,
1311 CreateAccountDetails,
1312 SignupResponse,
1313 UserInfo,
1314 headless_lms_authorization::ActionOnResource,
1315 headless_lms_authorization::Action,
1316 headless_lms_authorization::Resource,
1317 SendEmailCodeData,
1318 SendDeleteUserEmailCodeResult,
1319 EmailCode,
1320 DeleteUserAccountResult,
1321 VerifyEmailRequest,
1322 headless_lms_models::roles::UserRole,
1323 ))
1324)]
1325pub struct AuthRoutesApiDoc;
1326
1327pub fn _add_routes(cfg: &mut ServiceConfig) {
1328 cfg.service(
1329 web::resource("/signup")
1330 .wrap(RateLimit::new(RateLimitConfig {
1331 per_minute: Some(15),
1332 per_hour: None,
1333 per_day: Some(1000),
1334 per_month: None,
1335 ..Default::default()
1336 }))
1337 .to(signup),
1338 )
1339 .service(
1340 web::resource("/login")
1341 .wrap(RateLimit::new(RateLimitConfig {
1342 per_minute: Some(20),
1343 per_hour: Some(100),
1344 per_day: Some(500),
1345 per_month: None,
1346 ..Default::default()
1347 }))
1348 .to(login),
1349 )
1350 .route("/logout", web::post().to(logout))
1351 .route("/logged-in", web::get().to(logged_in))
1352 .route("/authorize", web::post().to(authorize_action_on_resource))
1353 .route(
1354 "/authorize-multiple",
1355 web::post().to(authorize_multiple_actions_on_resources),
1356 )
1357 .route("/user-info", web::get().to(user_info))
1358 .service(
1359 web::resource("/delete-user-account")
1360 .wrap(RateLimit::new(RateLimitConfig {
1361 per_minute: None,
1362 per_hour: Some(5),
1363 per_day: Some(10),
1364 per_month: None,
1365 ..Default::default()
1366 }))
1367 .to(delete_user_account),
1368 )
1369 .service(
1370 web::resource("/send-email-code")
1371 .wrap(RateLimit::new(RateLimitConfig {
1372 per_minute: None,
1373 per_hour: Some(5),
1374 per_day: Some(20),
1375 per_month: None,
1376 ..Default::default()
1377 }))
1378 .to(send_delete_user_email_code),
1379 )
1380 .service(
1381 web::resource("/verify-email")
1382 .wrap(RateLimit::new(RateLimitConfig {
1383 per_minute: Some(10),
1384 per_hour: Some(50),
1385 per_day: None,
1386 per_month: None,
1387 ..Default::default()
1388 }))
1389 .to(verify_email),
1390 );
1391}