headless_lms_server/controllers/
auth.rs

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