headless_lms_server/controllers/
auth.rs

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