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