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