Skip to main content

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