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