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