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