Skip to main content

headless_lms_server/controllers/
auth.rs

1/*!
2Handlers for HTTP requests to `/api/v0/auth`.
3*/
4
5use crate::domain::exercise_services::token::delete_user_and_invalidate_cached_tokens;
6use crate::{
7    OAuthClient,
8    domain::{
9        authentication,
10        authorization::{ActionOnResource, is_permitted, is_user_global_admin, skip_authorize},
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;
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    cache::Cache,
25    prelude::UtilErrorType,
26    services::tmc::{NewUserInfo, TmcClient},
27};
28use secrecy::{ExposeSecret, SecretString};
29use tracing_log::log;
30use utoipa::{OpenApi, ToSchema};
31
32#[derive(Debug, Deserialize, ToSchema)]
33pub struct Login {
34    pub email: String,
35    #[schema(value_type = String)]
36    pub password: SecretString,
37}
38
39#[derive(Debug, Serialize, Deserialize, ToSchema)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum LoginResponse {
42    Success,
43    RequiresEmailVerification {
44        #[schema(value_type = String)]
45        email_verification_token: OutboundSecret,
46    },
47    Failed,
48}
49
50#[derive(Debug, Serialize, Deserialize, ToSchema)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum SignupResponse {
53    Success,
54    EmailAlreadyExists,
55}
56
57/**
58POST `/api/v0/auth/authorize` checks whether user can perform specified action on specified resource.
59**/
60
61#[utoipa::path(
62    post,
63    path = "/authorize",
64    tag = "auth",
65    operation_id = "postAuthAuthorize",
66    request_body = ActionOnResource,
67    responses(
68        (status = 200, description = "Whether the action is allowed for the current user", body = bool)
69    )
70)]
71#[instrument(skip(pool, payload,))]
72pub async fn authorize_action_on_resource(
73    pool: web::Data<PgPool>,
74    user: Option<AuthUser>,
75    payload: web::Json<ActionOnResource>,
76) -> ControllerResult<web::Json<bool>> {
77    let mut conn = pool.acquire().await?;
78    let data = payload.0;
79    if let Some(user) = user {
80        match authorize(&mut conn, data.action, Some(user.id), data.resource).await {
81            Ok(true_token) => true_token.authorized_ok(web::Json(true)),
82            _ => {
83                // We went to return success message even if the authorization fails.
84                let false_token = skip_authorize();
85                false_token.authorized_ok(web::Json(false))
86            }
87        }
88    } else {
89        // Never authorize anonymous user
90        let false_token = skip_authorize();
91        false_token.authorized_ok(web::Json(false))
92    }
93}
94
95#[derive(Debug, Deserialize, ToSchema)]
96pub struct CreateAccountDetails {
97    pub email: String,
98    pub first_name: String,
99    pub last_name: String,
100    pub language: String,
101    #[schema(value_type = String)]
102    pub password: SecretString,
103    #[schema(value_type = String)]
104    pub password_confirmation: SecretString,
105    pub country: String,
106    pub email_communication_consent: bool,
107}
108
109/**
110POST `/api/v0/auth/signup` Creates new mooc.fi account and signs in.
111
112# Example
113```http
114POST /api/v0/auth/signup HTTP/1.1
115Content-Type: application/json
116
117{
118  "email": "student@example.com",
119  "first_name": "John",
120  "last_name": "Doe",
121  "language": "en",
122  "password": "hunter42",
123  "password_confirmation": "hunter42",
124  "country" : "Finland",
125  "email_communication_consent": true
126}
127```
128*/
129#[utoipa::path(
130    post,
131    path = "/signup",
132    tag = "auth",
133    operation_id = "postAuthSignup",
134    request_body = CreateAccountDetails,
135    responses(
136        (status = 200, description = "Signup outcome", body = SignupResponse),
137        (status = 400, description = "Cannot sign up (e.g. already signed in or validation error)")
138    )
139)]
140#[instrument(skip(session, pool, payload, app_conf))]
141pub async fn signup(
142    session: Session,
143    payload: web::Json<CreateAccountDetails>,
144    pool: web::Data<PgPool>,
145    user: Option<AuthUser>,
146    app_conf: web::Data<ApplicationConfiguration>,
147    tmc_client: web::Data<TmcClient>,
148) -> ControllerResult<web::Json<SignupResponse>> {
149    let user_details = payload.0;
150    let mut conn = pool.acquire().await?;
151
152    if app_conf.test_mode {
153        return handle_test_mode_signup(&mut conn, &session, &user_details, &app_conf).await;
154    }
155    if user.is_none() {
156        match models::users::get_by_email(&mut conn, &user_details.email).await {
157            Ok(_) => {
158                let token = skip_authorize();
159                return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
160            }
161            Err(error)
162                if matches!(
163                    error.error_type(),
164                    ModelErrorType::RecordNotFound | ModelErrorType::NotFound
165                ) => {}
166            Err(error) => return Err(error.into()),
167        }
168
169        let upstream_id = match tmc_client
170            .post_new_user_to_tmc(
171                NewUserInfo {
172                    first_name: user_details.first_name.clone(),
173                    last_name: user_details.last_name.clone(),
174                    email: user_details.email.clone(),
175                    password: user_details.password.clone(),
176                    password_confirmation: user_details.password_confirmation.clone(),
177                    language: user_details.language.clone(),
178                },
179                app_conf.as_ref(),
180            )
181            .await
182        {
183            Ok(upstream_id) => upstream_id,
184            Err(error) => {
185                let error_message = error.message().to_string();
186                if matches!(error.error_type(), &UtilErrorType::TmcErrorResponse)
187                    && is_duplicate_email_error_message(&error_message)
188                {
189                    let token = skip_authorize();
190                    return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
191                }
192                return match error.error_type() {
193                    UtilErrorType::TmcErrorResponse => {
194                        Err(controller_err!(BadRequest, error_message, anyhow!(error)))
195                    }
196                    UtilErrorType::TmcHttpError => Err(controller_err!(
197                        InternalServerError,
198                        error_message,
199                        anyhow!(error)
200                    )),
201                    _ => Err(controller_err!(
202                        InternalServerError,
203                        error_message,
204                        anyhow!(error)
205                    )),
206                };
207            }
208        };
209        let password_secret = user_details.password;
210
211        let user = models::users::insert_with_upstream_id_and_moocfi_id(
212            &mut conn,
213            &user_details.email,
214            Some(&user_details.first_name),
215            Some(&user_details.last_name),
216            upstream_id,
217            PKeyPolicy::Generate.into_uuid(),
218        )
219        .await;
220        let user = match user {
221            Ok(user) => user,
222            Err(error)
223                if matches!(
224                    error.error_type(),
225                    ModelErrorType::DatabaseConstraint { constraint, .. }
226                        if constraint == "users_email"
227                ) =>
228            {
229                let token = skip_authorize();
230                return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
231            }
232            // TMC synchronously posts the new user back to /api/v0/tmc-server/users/create
233            // while post_new_user_to_tmc is still in flight, so that callback has usually
234            // already created the user; continue with the existing row.
235            Err(error)
236                if matches!(
237                    error.error_type(),
238                    ModelErrorType::DatabaseConstraint { constraint, .. }
239                        if constraint == "users_upstream_id_active_uniq_idx"
240                ) =>
241            {
242                models::users::find_by_upstream_id(&mut conn, upstream_id)
243                    .await?
244                    .ok_or(error)?
245            }
246            Err(error) => {
247                return Err(controller_err!(
248                    InternalServerError,
249                    "Failed to insert user.".to_string(),
250                    anyhow!(error)
251                ));
252            }
253        };
254
255        let country = user_details.country.clone();
256        models::user_details::update_user_country(&mut conn, user.id, &country).await?;
257        models::user_details::update_user_email_communication_consent(
258            &mut conn,
259            user.id,
260            user_details.email_communication_consent,
261        )
262        .await?;
263
264        // Hash and save password to local database
265        let password_hash = models::user_passwords::hash_password(&password_secret)
266            .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
267
268        models::user_passwords::upsert_user_password(&mut conn, user.id, &password_hash)
269            .await
270            .map_err(|e| {
271                ControllerError::new(
272                    ControllerErrorType::InternalServerError,
273                    "Failed to add password to database".to_string(),
274                    anyhow!(e),
275                )
276            })?;
277
278        // Notify TMC that the password is now managed by courses.mooc.fi. Best-effort and retried
279        // in the background: the password is already stored locally, so a transient TMC outage
280        // must not fail an otherwise-successful signup.
281        crate::controllers::tmc_server::notify_password_managed_with_retry(
282            &tmc_client,
283            upstream_id.to_string(),
284            user.id,
285        )
286        .await;
287
288        // tmc.mooc.fi mails its own confirmation link but never tells us the outcome.
289        domain::email_ownership_verification::queue_verification_email_best_effort(
290            &mut conn,
291            app_conf.enable_email_ownership_verification,
292            user.id,
293        )
294        .await;
295
296        let token = skip_authorize();
297        authentication::remember(&session, user)?;
298        token.authorized_ok(web::Json(SignupResponse::Success))
299    } else {
300        Err(ControllerError::new(
301            ControllerErrorType::BadRequest,
302            "Cannot create a new account when signed in.".to_string(),
303            None,
304        ))
305    }
306}
307
308async fn handle_test_mode_signup(
309    conn: &mut PgConnection,
310    session: &Session,
311    user_details: &CreateAccountDetails,
312    app_conf: &ApplicationConfiguration,
313) -> ControllerResult<web::Json<SignupResponse>> {
314    assert!(
315        app_conf.test_mode,
316        "handle_test_mode_signup called outside test mode"
317    );
318
319    warn!("Handling signup in test mode. No real account is created.");
320
321    match models::users::get_by_email(conn, &user_details.email).await {
322        Ok(_) => {
323            let token = skip_authorize();
324            return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
325        }
326        Err(error)
327            if matches!(
328                error.error_type(),
329                ModelErrorType::RecordNotFound | ModelErrorType::NotFound
330            ) => {}
331        Err(error) => return Err(error.into()),
332    }
333
334    let user_id = models::users::insert(
335        conn,
336        PKeyPolicy::Generate,
337        &user_details.email,
338        Some(&user_details.first_name),
339        Some(&user_details.last_name),
340    )
341    .await;
342    let user_id = match user_id {
343        Ok(user_id) => user_id,
344        Err(error) => match error.error_type() {
345            ModelErrorType::DatabaseConstraint { constraint, .. }
346                if constraint == "users_email" =>
347            {
348                let token = skip_authorize();
349                return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
350            }
351            _ => {
352                return Err(controller_err!(
353                    InternalServerError,
354                    "Failed to insert test user.".to_string(),
355                    anyhow!(error)
356                ));
357            }
358        },
359    };
360
361    models::user_details::update_user_country(conn, user_id, &user_details.country).await?;
362    models::user_details::update_user_email_communication_consent(
363        conn,
364        user_id,
365        user_details.email_communication_consent,
366    )
367    .await?;
368
369    let user = models::users::get_by_email(conn, &user_details.email).await?;
370
371    let password_hash = models::user_passwords::hash_password(&user_details.password)
372        .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
373
374    models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
375        .await
376        .map_err(|e| {
377            ControllerError::new(
378                ControllerErrorType::InternalServerError,
379                "Failed to add password to database".to_string(),
380                anyhow!(e),
381            )
382        })?;
383    domain::email_ownership_verification::queue_verification_email_best_effort(
384        conn,
385        app_conf.enable_email_ownership_verification,
386        user.id,
387    )
388    .await;
389
390    authentication::remember(session, user)?;
391
392    let token = skip_authorize();
393    token.authorized_ok(web::Json(SignupResponse::Success))
394}
395
396fn is_duplicate_email_error_message(message: &str) -> bool {
397    let normalized = message.to_lowercase();
398    normalized.contains("email already exists")
399        || normalized.contains("email is already registered")
400        || normalized.contains("email already in use")
401        || normalized.contains("duplicate email")
402        || normalized.contains("unique constraint")
403        || normalized.contains("duplicate key")
404        || normalized.contains("users_email")
405        || normalized.contains("email_key")
406}
407
408/**
409POST `/api/v0/auth/authorize-multiple` checks whether user can perform specified action on specified resource.
410Returns booleans for the authorizations in the same order as the input.
411**/
412
413#[utoipa::path(
414    post,
415    path = "/authorize-multiple",
416    tag = "auth",
417    operation_id = "postAuthAuthorizeMultiple",
418    request_body = Vec<ActionOnResource>,
419    responses(
420        (status = 200, description = "Authorization result for each input action, in order", body = Vec<bool>)
421    )
422)]
423#[instrument(skip(pool, payload,))]
424pub async fn authorize_multiple_actions_on_resources(
425    pool: web::Data<PgPool>,
426    user: Option<AuthUser>,
427    payload: web::Json<Vec<ActionOnResource>>,
428) -> ControllerResult<web::Json<Vec<bool>>> {
429    let mut conn = pool.acquire().await?;
430    let input = payload.into_inner();
431    let mut results = Vec::with_capacity(input.len());
432    if let Some(user) = user {
433        // Prefetch roles so that we can do multiple authorizations without repeteadly querying the database.
434        let user_roles = models::roles::get_roles(&mut conn, user.id).await?;
435
436        for action_on_resource in input {
437            if is_permitted(
438                &mut conn,
439                action_on_resource.action,
440                action_on_resource.resource,
441                &user_roles,
442            )
443            .await
444            .unwrap_or(false)
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        authentication::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        authentication::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        authentication::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 = authentication::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            authentication::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    authentication::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 = authentication::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, cache, app_conf))]
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    app_conf: web::Data<ApplicationConfiguration>,
883    cache: web::Data<Cache>,
884) -> ControllerResult<web::Json<bool>> {
885    let token = skip_authorize();
886    if let Some(auth_user) = auth_user {
887        let mut conn = pool.acquire().await?;
888
889        // Check users code is valid
890        let code_ok = user_email_codes::is_reset_user_email_code_valid(
891            &mut conn,
892            auth_user.id,
893            UserEmailCodePurpose::AccountDeletion,
894            &payload.code,
895        )
896        .await?;
897
898        if !code_ok {
899            info!(
900                "User {} attempted account deletion with incorrect code",
901                auth_user.id
902            );
903            return token.authorized_ok(web::Json(false));
904        }
905
906        let mut tx = conn.begin().await?;
907        let user = users::get_by_id(&mut tx, auth_user.id).await?;
908
909        // Delete user from TMC if they have upstream_id
910        if let Some(upstream_id) = user.upstream_id {
911            let upstream_id_str = upstream_id.to_string();
912            let tmc_success = tmc_client
913                .delete_user_from_tmc(upstream_id_str)
914                .await
915                .unwrap_or(false);
916
917            if !tmc_success {
918                info!("TMC deletion failed for user {}", auth_user.id);
919                return token.authorized_ok(web::Json(false));
920            }
921        }
922
923        // Delete user locally and mark email code as used
924        delete_user_and_invalidate_cached_tokens(
925            &mut tx,
926            &cache,
927            &app_conf.oauth_server_configuration.oauth_token_hmac_key,
928            auth_user.id,
929        )
930        .await?;
931        user_email_codes::mark_user_email_code_used(
932            &mut tx,
933            auth_user.id,
934            UserEmailCodePurpose::AccountDeletion,
935            &payload.code,
936        )
937        .await?;
938
939        tx.commit().await?;
940        authentication::forget(&session);
941        token.authorized_ok(web::Json(true))
942    } else {
943        return token.authorized_ok(web::Json(false));
944    }
945}
946
947pub async fn update_user_information_to_tmc(
948    first_name: String,
949    last_name: String,
950    email: Option<String>,
951    user_upstream_id: String,
952    tmc_client: web::Data<TmcClient>,
953    app_conf: web::Data<ApplicationConfiguration>,
954) -> Result<(), Error> {
955    if app_conf.test_mode {
956        return Ok(());
957    }
958    tmc_client
959        .update_user_information(first_name, last_name, email, user_upstream_id)
960        .await
961        .map_err(|e| {
962            log::warn!("TMC user update failed: {:?}", e);
963            anyhow::anyhow!("TMC user update failed: {}", e)
964        })?;
965    Ok(())
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    authentication::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        headless_lms_authorization::ActionOnResource,
1174        headless_lms_authorization::Action,
1175        headless_lms_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}