Skip to main content

headless_lms_server/controllers/
auth.rs

1/*!
2Handlers for HTTP requests to `/api/v0/auth`.
3*/
4
5use crate::domain::exercise_services::token::delete_user_and_invalidate_cached_tokens;
6use crate::{
7    OAuthClient,
8    domain::{
9        authentication,
10        authorization::{ActionOnResource, is_permitted, is_user_global_admin, skip_authorize},
11        email_ownership_verification::{MAX_CODE_ATTEMPTS, MIN_RESEND_INTERVAL_MINUTES},
12        rate_limit_middleware_builder::{RateLimit, RateLimitConfig},
13    },
14    prelude::*,
15};
16use actix_session::Session;
17use anyhow::Error;
18use anyhow::anyhow;
19use chrono::Duration;
20use headless_lms_models::ModelErrorType;
21use headless_lms_models::{
22    email_templates::EmailTemplateType, email_verification_tokens, user_email_codes,
23    user_email_codes::UserEmailCodePurpose, user_passwords, users,
24};
25use headless_lms_utils::{
26    cache::Cache,
27    prelude::{UtilError, UtilErrorType},
28    services::tmc::{NewUserInfo, TmcAccountDeletion, TmcClient},
29};
30use secrecy::{ExposeSecret, SecretString};
31use tracing_log::log;
32use utoipa::{OpenApi, ToSchema};
33
34#[derive(Debug, Deserialize, ToSchema)]
35pub struct Login {
36    pub email: String,
37    #[schema(value_type = String)]
38    pub password: SecretString,
39}
40
41#[derive(Debug, Serialize, Deserialize, ToSchema)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum LoginResponse {
44    Success,
45    RequiresEmailVerification {
46        #[schema(value_type = String)]
47        email_verification_token: OutboundSecret,
48    },
49    Failed,
50}
51
52#[derive(Debug, Serialize, Deserialize, ToSchema)]
53#[serde(tag = "type", rename_all = "snake_case")]
54pub enum SignupResponse {
55    Success,
56    EmailAlreadyExists,
57}
58
59/**
60POST `/api/v0/auth/authorize` checks whether user can perform specified action on specified resource.
61**/
62
63#[utoipa::path(
64    post,
65    path = "/authorize",
66    tag = "auth",
67    operation_id = "postAuthAuthorize",
68    request_body = ActionOnResource,
69    responses(
70        (status = 200, description = "Whether the action is allowed for the current user", body = bool)
71    )
72)]
73#[instrument(skip(pool, payload,))]
74pub async fn authorize_action_on_resource(
75    pool: web::Data<PgPool>,
76    user: Option<AuthUser>,
77    payload: web::Json<ActionOnResource>,
78) -> ControllerResult<web::Json<bool>> {
79    let mut conn = pool.acquire().await?;
80    let data = payload.0;
81    if let Some(user) = user {
82        match authorize(&mut conn, data.action, Some(user.id), data.resource).await {
83            Ok(true_token) => true_token.authorized_ok(web::Json(true)),
84            _ => {
85                // We went to return success message even if the authorization fails.
86                let false_token = skip_authorize();
87                false_token.authorized_ok(web::Json(false))
88            }
89        }
90    } else {
91        // Never authorize anonymous user
92        let false_token = skip_authorize();
93        false_token.authorized_ok(web::Json(false))
94    }
95}
96
97#[derive(Debug, Deserialize, ToSchema)]
98pub struct CreateAccountDetails {
99    pub email: String,
100    pub first_name: String,
101    pub last_name: String,
102    pub language: String,
103    #[schema(value_type = String)]
104    pub password: SecretString,
105    #[schema(value_type = String)]
106    pub password_confirmation: SecretString,
107    pub country: String,
108    pub email_communication_consent: bool,
109}
110
111/**
112POST `/api/v0/auth/signup` Creates new mooc.fi account and signs in.
113
114# Example
115```http
116POST /api/v0/auth/signup HTTP/1.1
117Content-Type: application/json
118
119{
120  "email": "student@example.com",
121  "first_name": "John",
122  "last_name": "Doe",
123  "language": "en",
124  "password": "hunter42",
125  "password_confirmation": "hunter42",
126  "country" : "Finland",
127  "email_communication_consent": true
128}
129```
130*/
131#[utoipa::path(
132    post,
133    path = "/signup",
134    tag = "auth",
135    operation_id = "postAuthSignup",
136    request_body = CreateAccountDetails,
137    responses(
138        (status = 200, description = "Signup outcome", body = SignupResponse),
139        (status = 400, description = "Cannot sign up (e.g. already signed in or validation error)")
140    )
141)]
142#[instrument(skip(session, pool, payload, app_conf))]
143pub async fn signup(
144    session: Session,
145    payload: web::Json<CreateAccountDetails>,
146    pool: web::Data<PgPool>,
147    user: Option<AuthUser>,
148    app_conf: web::Data<ApplicationConfiguration>,
149    tmc_client: web::Data<TmcClient>,
150) -> ControllerResult<web::Json<SignupResponse>> {
151    let user_details = payload.0;
152    let mut conn = pool.acquire().await?;
153
154    if app_conf.test_mode {
155        return handle_test_mode_signup(&mut conn, &session, &user_details, &app_conf).await;
156    }
157    if user.is_none() {
158        match models::users::get_by_email(&mut conn, &user_details.email).await {
159            Ok(_) => {
160                let token = skip_authorize();
161                return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
162            }
163            Err(error)
164                if matches!(
165                    error.error_type(),
166                    ModelErrorType::RecordNotFound | ModelErrorType::NotFound
167                ) => {}
168            Err(error) => return Err(error.into()),
169        }
170
171        let upstream_id = match tmc_client
172            .post_new_user_to_tmc(
173                NewUserInfo {
174                    first_name: user_details.first_name.clone(),
175                    last_name: user_details.last_name.clone(),
176                    email: user_details.email.clone(),
177                    password: user_details.password.clone(),
178                    password_confirmation: user_details.password_confirmation.clone(),
179                    language: user_details.language.clone(),
180                },
181                app_conf.as_ref(),
182            )
183            .await
184        {
185            Ok(upstream_id) => upstream_id,
186            Err(error) => {
187                let error_message = error.message().to_string();
188                if matches!(error.error_type(), &UtilErrorType::TmcErrorResponse)
189                    && is_duplicate_email_error_message(&error_message)
190                {
191                    let token = skip_authorize();
192                    return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
193                }
194                return match error.error_type() {
195                    UtilErrorType::TmcErrorResponse => {
196                        Err(controller_err!(BadRequest, error_message, anyhow!(error)))
197                    }
198                    UtilErrorType::TmcHttpError => Err(controller_err!(
199                        InternalServerError,
200                        error_message,
201                        anyhow!(error)
202                    )),
203                    _ => Err(controller_err!(
204                        InternalServerError,
205                        error_message,
206                        anyhow!(error)
207                    )),
208                };
209            }
210        };
211        let password_secret = user_details.password;
212
213        let user = models::users::insert_with_upstream_id_and_moocfi_id(
214            &mut conn,
215            &user_details.email,
216            Some(&user_details.first_name),
217            Some(&user_details.last_name),
218            upstream_id,
219            PKeyPolicy::Generate.into_uuid(),
220        )
221        .await;
222        let user = match user {
223            Ok(user) => user,
224            Err(error)
225                if matches!(
226                    error.error_type(),
227                    ModelErrorType::DatabaseConstraint { constraint, .. }
228                        if constraint == "users_email"
229                ) =>
230            {
231                let token = skip_authorize();
232                return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
233            }
234            // TMC synchronously posts the new user back to /api/v0/tmc-server/users/create
235            // while post_new_user_to_tmc is still in flight, so that callback has usually
236            // already created the user; continue with the existing row.
237            Err(error)
238                if matches!(
239                    error.error_type(),
240                    ModelErrorType::DatabaseConstraint { constraint, .. }
241                        if constraint == "users_upstream_id_active_uniq_idx"
242                ) =>
243            {
244                models::users::find_by_upstream_id(&mut conn, upstream_id)
245                    .await?
246                    .ok_or(error)?
247            }
248            Err(error) => {
249                return Err(controller_err!(
250                    InternalServerError,
251                    "Failed to insert user.".to_string(),
252                    anyhow!(error)
253                ));
254            }
255        };
256
257        let country = user_details.country.clone();
258        models::user_details::update_user_country(&mut conn, user.id, &country).await?;
259        models::user_details::update_user_email_communication_consent(
260            &mut conn,
261            user.id,
262            user_details.email_communication_consent,
263        )
264        .await?;
265
266        // Hash and save password to local database
267        let password_hash = models::user_passwords::hash_password(&password_secret)
268            .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
269
270        models::user_passwords::upsert_user_password(&mut conn, user.id, &password_hash)
271            .await
272            .map_err(|e| {
273                ControllerError::new(
274                    ControllerErrorType::InternalServerError,
275                    "Failed to add password to database".to_string(),
276                    anyhow!(e),
277                )
278            })?;
279
280        // Notify TMC that the password is now managed by courses.mooc.fi. Best-effort and retried
281        // in the background: the password is already stored locally, so a transient TMC outage
282        // must not fail an otherwise-successful signup.
283        crate::controllers::tmc_server::notify_password_managed_with_retry(
284            &tmc_client,
285            upstream_id.to_string(),
286            user.id,
287        )
288        .await;
289
290        // tmc.mooc.fi mails its own confirmation link but never tells us the outcome.
291        domain::email_ownership_verification::queue_verification_email_best_effort(
292            &mut conn,
293            app_conf.enable_email_ownership_verification,
294            user.id,
295        )
296        .await;
297
298        let token = skip_authorize();
299        authentication::remember(&session, user)?;
300        token.authorized_ok(web::Json(SignupResponse::Success))
301    } else {
302        Err(ControllerError::new(
303            ControllerErrorType::BadRequest,
304            "Cannot create a new account when signed in.".to_string(),
305            None,
306        ))
307    }
308}
309
310async fn handle_test_mode_signup(
311    conn: &mut PgConnection,
312    session: &Session,
313    user_details: &CreateAccountDetails,
314    app_conf: &ApplicationConfiguration,
315) -> ControllerResult<web::Json<SignupResponse>> {
316    assert!(
317        app_conf.test_mode,
318        "handle_test_mode_signup called outside test mode"
319    );
320
321    warn!("Handling signup in test mode. No real account is created.");
322
323    match models::users::get_by_email(conn, &user_details.email).await {
324        Ok(_) => {
325            let token = skip_authorize();
326            return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
327        }
328        Err(error)
329            if matches!(
330                error.error_type(),
331                ModelErrorType::RecordNotFound | ModelErrorType::NotFound
332            ) => {}
333        Err(error) => return Err(error.into()),
334    }
335
336    let user_id = models::users::insert(
337        conn,
338        PKeyPolicy::Generate,
339        &user_details.email,
340        Some(&user_details.first_name),
341        Some(&user_details.last_name),
342    )
343    .await;
344    let user_id = match user_id {
345        Ok(user_id) => user_id,
346        Err(error) => match error.error_type() {
347            ModelErrorType::DatabaseConstraint { constraint, .. }
348                if constraint == "users_email" =>
349            {
350                let token = skip_authorize();
351                return token.authorized_ok(web::Json(SignupResponse::EmailAlreadyExists));
352            }
353            _ => {
354                return Err(controller_err!(
355                    InternalServerError,
356                    "Failed to insert test user.".to_string(),
357                    anyhow!(error)
358                ));
359            }
360        },
361    };
362
363    models::user_details::update_user_country(conn, user_id, &user_details.country).await?;
364    models::user_details::update_user_email_communication_consent(
365        conn,
366        user_id,
367        user_details.email_communication_consent,
368    )
369    .await?;
370
371    let user = models::users::get_by_email(conn, &user_details.email).await?;
372
373    let password_hash = models::user_passwords::hash_password(&user_details.password)
374        .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
375
376    models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
377        .await
378        .map_err(|e| {
379            ControllerError::new(
380                ControllerErrorType::InternalServerError,
381                "Failed to add password to database".to_string(),
382                anyhow!(e),
383            )
384        })?;
385    domain::email_ownership_verification::queue_verification_email_best_effort(
386        conn,
387        app_conf.enable_email_ownership_verification,
388        user.id,
389    )
390    .await;
391
392    authentication::remember(session, user)?;
393
394    let token = skip_authorize();
395    token.authorized_ok(web::Json(SignupResponse::Success))
396}
397
398fn is_duplicate_email_error_message(message: &str) -> bool {
399    let normalized = message.to_lowercase();
400    normalized.contains("email already exists")
401        || normalized.contains("email is already registered")
402        || normalized.contains("email already in use")
403        || normalized.contains("duplicate email")
404        || normalized.contains("unique constraint")
405        || normalized.contains("duplicate key")
406        || normalized.contains("users_email")
407        || normalized.contains("email_key")
408}
409
410/**
411POST `/api/v0/auth/authorize-multiple` checks whether user can perform specified action on specified resource.
412Returns booleans for the authorizations in the same order as the input.
413**/
414
415#[utoipa::path(
416    post,
417    path = "/authorize-multiple",
418    tag = "auth",
419    operation_id = "postAuthAuthorizeMultiple",
420    request_body = Vec<ActionOnResource>,
421    responses(
422        (status = 200, description = "Authorization result for each input action, in order", body = Vec<bool>)
423    )
424)]
425#[instrument(skip(pool, payload,))]
426pub async fn authorize_multiple_actions_on_resources(
427    pool: web::Data<PgPool>,
428    user: Option<AuthUser>,
429    payload: web::Json<Vec<ActionOnResource>>,
430) -> ControllerResult<web::Json<Vec<bool>>> {
431    let mut conn = pool.acquire().await?;
432    let input = payload.into_inner();
433    let mut results = Vec::with_capacity(input.len());
434    if let Some(user) = user {
435        // Prefetch roles so that we can do multiple authorizations without repeteadly querying the database.
436        let user_roles = models::roles::get_roles(&mut conn, user.id).await?;
437
438        for action_on_resource in input {
439            if is_permitted(
440                &mut conn,
441                action_on_resource.action,
442                action_on_resource.resource,
443                &user_roles,
444            )
445            .await
446            .unwrap_or(false)
447            {
448                results.push(true);
449            } else {
450                results.push(false);
451            }
452        }
453    } else {
454        // Never authorize anonymous user
455        for _action_on_resource in input {
456            results.push(false);
457        }
458    }
459    let token = skip_authorize();
460    token.authorized_ok(web::Json(results))
461}
462
463/**
464POST `/api/v0/auth/login` Logs in to the system.
465Returns LoginResponse indicating success, email verification required, or failure.
466**/
467#[utoipa::path(
468    post,
469    path = "/login",
470    tag = "auth",
471    operation_id = "postAuthLogin",
472    request_body = Login,
473    responses(
474        (status = 200, description = "Login outcome", body = LoginResponse)
475    )
476)]
477#[instrument(skip(session, pool, client, payload, app_conf, tmc_client))]
478pub async fn login(
479    session: Session,
480    pool: web::Data<PgPool>,
481    client: web::Data<OAuthClient>,
482    app_conf: web::Data<ApplicationConfiguration>,
483    payload: web::Json<Login>,
484    tmc_client: web::Data<TmcClient>,
485) -> ControllerResult<web::Json<LoginResponse>> {
486    let mut conn = pool.acquire().await?;
487    let Login { email, password } = payload.into_inner();
488
489    // Development mode UUID login (allows logging in with a user ID string)
490    if app_conf.development_uuid_login {
491        return handle_uuid_login(&session, &mut conn, &email, &app_conf).await;
492    }
493
494    // Test mode: authenticate using seeded test credentials or stored password
495    if app_conf.test_mode {
496        return handle_test_mode_login(&session, &mut conn, &email, &password, &app_conf).await;
497    };
498
499    return handle_production_login(
500        &session,
501        &mut conn,
502        &client,
503        &tmc_client,
504        &email,
505        &password,
506        &app_conf,
507    )
508    .await;
509}
510
511async fn handle_uuid_login(
512    session: &Session,
513    conn: &mut PgConnection,
514    email: &str,
515    app_conf: &ApplicationConfiguration,
516) -> ControllerResult<web::Json<LoginResponse>> {
517    warn!("Trying development mode UUID login");
518    let token = skip_authorize();
519
520    if let Ok(id) = Uuid::parse_str(email) {
521        let user = { models::users::get_by_id(conn, id).await? };
522        let is_admin = is_user_global_admin(conn, user.id).await?;
523
524        if app_conf.enable_admin_email_verification && is_admin {
525            return handle_email_verification(conn, &user).await;
526        }
527
528        authentication::remember(session, user)?;
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
536async fn handle_test_mode_login(
537    session: &Session,
538    conn: &mut PgConnection,
539    email: &str,
540    password: &SecretString,
541    app_conf: &ApplicationConfiguration,
542) -> ControllerResult<web::Json<LoginResponse>> {
543    warn!("Using test credentials. Normal accounts won't work.");
544
545    let user = match models::users::get_by_email(conn, email).await {
546        Ok(u) => u,
547        Err(_) => {
548            warn!("Test user not found for {}", email);
549            let token = skip_authorize();
550            return token.authorized_ok(web::Json(LoginResponse::Failed));
551        }
552    };
553
554    let mut is_authenticated =
555        authentication::authenticate_test_user(conn, email, password, app_conf)
556            .await
557            .map_err(|e| {
558                ControllerError::new(
559                    ControllerErrorType::Unauthorized,
560                    "Could not find the test user. Have you seeded the database?".to_string(),
561                    e,
562                )
563            })?;
564
565    if !is_authenticated {
566        is_authenticated =
567            models::user_passwords::verify_user_password(conn, user.id, password).await?;
568    }
569
570    if is_authenticated {
571        info!("Authentication successful");
572        let is_admin = is_user_global_admin(conn, user.id).await?;
573
574        if app_conf.enable_admin_email_verification && is_admin {
575            return handle_email_verification(conn, &user).await;
576        }
577
578        authentication::remember(session, user)?;
579    } else {
580        warn!("Authentication failed");
581    }
582
583    let token = skip_authorize();
584    if is_authenticated {
585        token.authorized_ok(web::Json(LoginResponse::Success))
586    } else {
587        token.authorized_ok(web::Json(LoginResponse::Failed))
588    }
589}
590
591async fn handle_production_login(
592    session: &Session,
593    conn: &mut PgConnection,
594    client: &OAuthClient,
595    tmc_client: &TmcClient,
596    email: &str,
597    password: &SecretString,
598    app_conf: &ApplicationConfiguration,
599) -> ControllerResult<web::Json<LoginResponse>> {
600    // Trim incidental whitespace (e.g. from copy-paste) so the email resolves consistently with
601    // the reset-email path, which also trims. Case is handled by lower(...) in get_by_email.
602    let email = email.trim();
603    let mut is_authenticated = false;
604    let mut authenticated_user: Option<headless_lms_models::users::User> = None;
605
606    // Try to authenticate using password stored in courses.mooc.fi database
607    if let Ok(user) = models::users::get_by_email(conn, email).await {
608        let is_password_stored =
609            models::user_passwords::check_if_users_password_is_stored(conn, user.id).await?;
610        if is_password_stored {
611            is_authenticated =
612                models::user_passwords::verify_user_password(conn, user.id, password).await?;
613
614            if is_authenticated {
615                info!("Authentication successful");
616                authenticated_user = Some(user);
617            }
618        }
619    }
620
621    // Try to authenticate via TMC and store password to courses.mooc.fi if successful
622    if !is_authenticated {
623        let auth_result = authentication::authenticate_tmc_mooc_fi_user(
624            conn,
625            client,
626            email.to_string(),
627            password.clone(),
628            tmc_client,
629        )
630        .await?;
631
632        if let Some((user, _token)) = auth_result {
633            // If user is autenticated in TMC successfully, hash password and save it to courses.mooc.fi database
634            let password_hash = models::user_passwords::hash_password(password)
635                .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
636
637            models::user_passwords::upsert_user_password(conn, user.id, &password_hash)
638                .await
639                .map_err(|e| {
640                    ControllerError::new(
641                        ControllerErrorType::InternalServerError,
642                        "Failed to add password to database".to_string(),
643                        anyhow!(e),
644                    )
645                })?;
646
647            // Notify TMC that the password is now managed by courses.mooc.fi. Best-effort and
648            // retried in the background: the password is already stored locally, so a transient
649            // TMC outage must not fail an otherwise-successful login.
650            if let Some(upstream_id) = user.upstream_id {
651                crate::controllers::tmc_server::notify_password_managed_with_retry(
652                    tmc_client,
653                    upstream_id.to_string(),
654                    user.id,
655                )
656                .await;
657            } else {
658                warn!("User has no upstream_id; skipping notify to TMC");
659            }
660            info!("Authentication successful");
661            authenticated_user = Some(user);
662            is_authenticated = true;
663        }
664    }
665
666    let token = skip_authorize();
667    if is_authenticated {
668        if let Some(user) = authenticated_user {
669            let is_admin = is_user_global_admin(conn, user.id).await?;
670
671            if app_conf.enable_admin_email_verification && is_admin {
672                return handle_email_verification(conn, &user).await;
673            }
674
675            authentication::remember(session, user)?;
676        }
677        token.authorized_ok(web::Json(LoginResponse::Success))
678    } else {
679        warn!("Authentication failed");
680        token.authorized_ok(web::Json(LoginResponse::Failed))
681    }
682}
683
684/**
685POST `/api/v0/auth/logout` Logs out.
686**/
687#[utoipa::path(
688    post,
689    path = "/logout",
690    tag = "auth",
691    operation_id = "postAuthLogout",
692    responses((status = 200, description = "Session cleared"))
693)]
694#[instrument(skip(session))]
695#[allow(clippy::async_yields_async)]
696pub async fn logout(session: Session) -> HttpResponse {
697    authentication::forget(&session);
698    HttpResponse::Ok().finish()
699}
700
701/**
702GET `/api/v0/auth/logged-in` Returns the current user's login status.
703**/
704#[utoipa::path(
705    get,
706    path = "/logged-in",
707    tag = "auth",
708    operation_id = "getAuthLoggedIn",
709    responses(
710        (status = 200, description = "True when an authenticated session exists", body = bool)
711    )
712)]
713#[instrument(skip(session))]
714pub async fn logged_in(session: Session, pool: web::Data<PgPool>) -> web::Json<bool> {
715    let logged_in = authentication::has_auth_user_session(&session, pool).await;
716    web::Json(logged_in)
717}
718
719/// Generic information about the logged in user.
720///
721///  Could include the user name etc in the future.
722#[derive(Debug, Serialize, Deserialize, ToSchema)]
723
724pub struct UserInfo {
725    pub user_id: Uuid,
726    pub first_name: Option<String>,
727    pub last_name: Option<String>,
728}
729
730/**
731GET `/api/v0/auth/user-info` Returns the current user's info.
732**/
733
734#[utoipa::path(
735    get,
736    path = "/user-info",
737    tag = "auth",
738    operation_id = "getAuthUserInfo",
739    responses(
740        (status = 200, description = "Profile when signed in; null when anonymous", body = Option<UserInfo>)
741    )
742)]
743#[instrument(skip(auth_user, pool))]
744pub async fn user_info(
745    auth_user: Option<AuthUser>,
746    pool: web::Data<PgPool>,
747) -> ControllerResult<web::Json<Option<UserInfo>>> {
748    let token = skip_authorize();
749    if let Some(auth_user) = auth_user {
750        let mut conn = pool.acquire().await?;
751        let user_details =
752            models::user_details::get_user_details_by_user_id(&mut conn, auth_user.id).await?;
753
754        token.authorized_ok(web::Json(Some(UserInfo {
755            user_id: user_details.user_id,
756            first_name: user_details.first_name,
757            last_name: user_details.last_name,
758        })))
759    } else {
760        token.authorized_ok(web::Json(None))
761    }
762}
763
764#[derive(Debug, Deserialize, ToSchema)]
765pub struct SendEmailCodeData {
766    #[schema(value_type = String)]
767    pub password: SecretString,
768    pub language: String,
769}
770
771/// Outcome of asking for an account deletion code.
772#[derive(Debug, Serialize, Deserialize, ToSchema)]
773#[serde(tag = "type", rename_all = "snake_case")]
774pub enum SendDeleteUserEmailCodeResult {
775    Queued,
776    /// A code was mailed less than [`MIN_RESEND_INTERVAL_MINUTES`] ago and is still usable.
777    RecentlySent {
778        retry_after_seconds: i64,
779    },
780    IncorrectPassword,
781}
782
783/**
784POST `/api/v0/auth/send-email-code` If users password is correct, sends a code to users email for account deletion
785**/
786#[utoipa::path(
787    post,
788    path = "/send-email-code",
789    tag = "auth",
790    operation_id = "postAuthSendEmailCode",
791    request_body = SendEmailCodeData,
792    responses(
793        (status = 200, description = "What the request did", body = SendDeleteUserEmailCodeResult)
794    )
795)]
796#[instrument(skip(pool, payload, auth_user))]
797pub async fn send_delete_user_email_code(
798    auth_user: AuthUser,
799    pool: web::Data<PgPool>,
800    payload: web::Json<SendEmailCodeData>,
801) -> ControllerResult<web::Json<SendDeleteUserEmailCodeResult>> {
802    let token = skip_authorize();
803    let mut conn = pool.acquire().await?;
804
805    let password_ok =
806        user_passwords::verify_user_password(&mut conn, auth_user.id, &payload.password).await?;
807
808    if !password_ok {
809        info!(
810            "User {} attempted account deletion with incorrect password",
811            auth_user.id
812        );
813        return token.authorized_ok(web::Json(SendDeleteUserEmailCodeResult::IncorrectPassword));
814    }
815
816    let live_code = user_email_codes::get_unused_user_email_code_with_user_id(
817        &mut conn,
818        auth_user.id,
819        UserEmailCodePurpose::AccountDeletion,
820    )
821    .await?;
822    if let Some(live_code) = live_code {
823        let retry_after_seconds =
824            (live_code.created_at + Duration::minutes(MIN_RESEND_INTERVAL_MINUTES) - Utc::now())
825                .num_seconds();
826        if retry_after_seconds > 0 {
827            return token.authorized_ok(web::Json(SendDeleteUserEmailCodeResult::RecentlySent {
828                retry_after_seconds,
829            }));
830        }
831    }
832
833    let language = &payload.language;
834    let delete_template = models::email_templates::get_generic_email_template_by_type_and_language(
835        &mut conn,
836        EmailTemplateType::DeleteUserEmail,
837        language,
838    )
839    .await
840    .map_err(|_e| {
841        anyhow::anyhow!(
842            "Account deletion email template not configured. Missing template 'delete-user-email' for language '{}'",
843            language
844        )
845    })?;
846
847    // A fresh code every time: mailing the outstanding one again would keep a single code alive for
848    // as long as the user kept pressing resend.
849    let code = user_email_codes::generate_code();
850
851    let mut tx = conn.begin().await?;
852    user_email_codes::insert_user_email_code(
853        &mut tx,
854        auth_user.id,
855        UserEmailCodePurpose::AccountDeletion,
856        &code,
857    )
858    .await?;
859    models::email_deliveries::insert_email_delivery(&mut tx, auth_user.id, delete_template.id)
860        .await?;
861    tx.commit().await?;
862
863    token.authorized_ok(web::Json(SendDeleteUserEmailCodeResult::Queued))
864}
865
866#[derive(Debug, Deserialize, ToSchema)]
867
868pub struct EmailCode {
869    #[schema(value_type = String)]
870    pub code: DbSecret,
871}
872
873/// Outcome of spending an account deletion code.
874#[derive(Debug, Serialize, Deserialize, ToSchema)]
875#[serde(tag = "type", rename_all = "snake_case")]
876pub enum DeleteUserAccountResult {
877    Deleted,
878    /// Wrong, superseded and spent are one value: they are indistinguishable to someone typing
879    /// digits, and telling them apart only helps a guesser.
880    InvalidCode,
881    /// The outstanding code timed out. Read off that code's own expiry, so it reveals nothing about
882    /// what was typed.
883    ExpiredCode,
884    /// The code was retired after too many wrong guesses; only a new code can get past this.
885    TooManyAttempts,
886    /// tmc.mooc.fi could not be reached. Nothing was deleted and retrying is safe.
887    UpstreamUnavailable,
888    /// tmc.mooc.fi refused the deletion. `reference` identifies the recorded error, and is the only
889    /// thing the user can hand to support.
890    UpstreamRejected {
891        reference: Uuid,
892    },
893}
894
895/// How a failed TMC delete should be treated. The upstream status is what separates a retry that
896/// may work from one that never will.
897enum TmcDeletionFailure {
898    /// TMC has no such account, so the local deletion is still the right thing to do.
899    AlreadyGone,
900    Transient,
901    Rejected,
902}
903
904fn classify_tmc_deletion_failure(error: &UtilError) -> TmcDeletionFailure {
905    match error.error_type() {
906        // The request never completed, so TMC provably did not act on it.
907        UtilErrorType::TmcHttpError => TmcDeletionFailure::Transient,
908        // The deletion contract reports an unknown user in the body, but deployments predating it
909        // answer a bare 404.
910        UtilErrorType::TmcHttpStatusError(404) => TmcDeletionFailure::AlreadyGone,
911        UtilErrorType::TmcHttpStatusError(429) => TmcDeletionFailure::Transient,
912        UtilErrorType::TmcHttpStatusError(status) if *status >= 500 => {
913            TmcDeletionFailure::Transient
914        }
915        _ => TmcDeletionFailure::Rejected,
916    }
917}
918
919/// Records a TMC refusal under `reference` in `error_variants`/`error_occurrences`, so support can
920/// find it from the reference alone.
921///
922/// Best effort: the deletion has already failed, and failing to record that must not turn a
923/// reported outcome into a 500.
924async fn report_tmc_deletion_rejection(
925    conn: &mut PgConnection,
926    user_id: Uuid,
927    reference: Uuid,
928    error: &UtilError,
929) {
930    let report = models::errors::NewErrorReport {
931        service: "headless-lms".to_string(),
932        error_source: Some(models::errors::ErrorSource::Backend),
933        message: format!(
934            "TMC refused to delete the account of user {user_id} (reference {reference}): {error}"
935        ),
936        stack_trace: Some(format!("{error:?}")),
937        path: None,
938        app_version: None,
939        details: Some(serde_json::json!({
940            "kind": "tmc_account_deletion_rejected",
941            "reference": reference,
942            "user_id": user_id,
943        })),
944    };
945    if let Err(e) = models::errors::insert(conn, Some(user_id), &report).await {
946        warn!("Could not record TMC account deletion rejection {reference}: {e}");
947    }
948}
949
950/**
951POST `/api/v0/auth/delete-user-account` If users single-use code is correct then delete users account
952**/
953#[utoipa::path(
954    post,
955    path = "/delete-user-account",
956    tag = "auth",
957    operation_id = "postAuthDeleteUserAccount",
958    request_body = EmailCode,
959    responses(
960        (status = 200, description = "Outcome of submitting the code", body = DeleteUserAccountResult)
961    )
962)]
963#[instrument(skip(pool, payload, auth_user, session, cache, app_conf))]
964pub async fn delete_user_account(
965    auth_user: AuthUser,
966    pool: web::Data<PgPool>,
967    payload: web::Json<EmailCode>,
968    session: Session,
969    tmc_client: web::Data<TmcClient>,
970    app_conf: web::Data<ApplicationConfiguration>,
971    cache: web::Data<Cache>,
972) -> ControllerResult<web::Json<DeleteUserAccountResult>> {
973    let token = skip_authorize();
974    let mut conn = pool.acquire().await?;
975
976    let code_ok = user_email_codes::is_reset_user_email_code_valid(
977        &mut conn,
978        auth_user.id,
979        UserEmailCodePurpose::AccountDeletion,
980        &payload.code,
981    )
982    .await?;
983
984    if !code_ok {
985        let outstanding = user_email_codes::get_outstanding_user_email_code(
986            &mut conn,
987            auth_user.id,
988            UserEmailCodePurpose::AccountDeletion,
989        )
990        .await?;
991        // Before counting the guess: attempts against a code that can no longer be spent would
992        // only strand the user on "too many attempts" for a code they have to replace anyway.
993        if outstanding.is_some_and(|code| code.expires_at <= Utc::now()) {
994            info!(
995                "User {} attempted account deletion with an expired code",
996                auth_user.id
997            );
998            return token.authorized_ok(web::Json(DeleteUserAccountResult::ExpiredCode));
999        }
1000
1001        info!(
1002            "User {} attempted account deletion with incorrect code",
1003            auth_user.id
1004        );
1005        let code_retired = user_email_codes::record_failed_attempt(
1006            &mut conn,
1007            auth_user.id,
1008            UserEmailCodePurpose::AccountDeletion,
1009            MAX_CODE_ATTEMPTS,
1010        )
1011        .await?;
1012        return token.authorized_ok(web::Json(if code_retired {
1013            DeleteUserAccountResult::TooManyAttempts
1014        } else {
1015            DeleteUserAccountResult::InvalidCode
1016        }));
1017    }
1018
1019    let user = users::get_by_id(&mut conn, auth_user.id).await?;
1020
1021    // Outside the transaction below: this can wait minutes on tmc.mooc.fi, and a transaction held
1022    // open that long pins a pool connection with it.
1023    if let Some(upstream_id) = user.upstream_id
1024        && !app_conf.test_mode
1025    {
1026        match tmc_client
1027            .delete_user_from_tmc(upstream_id.to_string())
1028            .await
1029        {
1030            Ok(TmcAccountDeletion::Deleted) => {}
1031            Ok(TmcAccountDeletion::AlreadyDeleted) => {
1032                info!(
1033                    "TMC reported account {upstream_id} of user {} as already deleted; deleting locally anyway",
1034                    auth_user.id
1035                );
1036            }
1037            Err(error) => match classify_tmc_deletion_failure(&error) {
1038                TmcDeletionFailure::AlreadyGone => {
1039                    info!(
1040                        "TMC has no account {upstream_id} for user {}; deleting locally anyway: {error}",
1041                        auth_user.id
1042                    );
1043                }
1044                TmcDeletionFailure::Transient => {
1045                    warn!(
1046                        "TMC was unavailable while deleting the account of user {}: {error}",
1047                        auth_user.id
1048                    );
1049                    return token
1050                        .authorized_ok(web::Json(DeleteUserAccountResult::UpstreamUnavailable));
1051                }
1052                TmcDeletionFailure::Rejected => {
1053                    let reference = Uuid::new_v4();
1054                    error!(
1055                        "TMC refused to delete the account of user {} (reference {reference}): {error}",
1056                        auth_user.id
1057                    );
1058                    report_tmc_deletion_rejection(&mut conn, auth_user.id, reference, &error).await;
1059                    return token.authorized_ok(web::Json(
1060                        DeleteUserAccountResult::UpstreamRejected { reference },
1061                    ));
1062                }
1063            },
1064        }
1065    }
1066
1067    let mut tx = conn.begin().await?;
1068    delete_user_and_invalidate_cached_tokens(
1069        &mut tx,
1070        &cache,
1071        &app_conf.oauth_server_configuration.oauth_token_hmac_key,
1072        auth_user.id,
1073    )
1074    .await?;
1075    user_email_codes::mark_user_email_code_used(
1076        &mut tx,
1077        auth_user.id,
1078        UserEmailCodePurpose::AccountDeletion,
1079        &payload.code,
1080    )
1081    .await?;
1082    tx.commit().await?;
1083
1084    authentication::forget(&session);
1085    token.authorized_ok(web::Json(DeleteUserAccountResult::Deleted))
1086}
1087
1088pub async fn update_user_information_to_tmc(
1089    first_name: String,
1090    last_name: String,
1091    email: Option<String>,
1092    user_upstream_id: String,
1093    tmc_client: web::Data<TmcClient>,
1094    app_conf: web::Data<ApplicationConfiguration>,
1095) -> Result<(), Error> {
1096    if app_conf.test_mode {
1097        return Ok(());
1098    }
1099    tmc_client
1100        .update_user_information(first_name, last_name, email, user_upstream_id)
1101        .await
1102        .map_err(|e| {
1103            log::warn!("TMC user update failed: {:?}", e);
1104            anyhow::anyhow!("TMC user update failed: {}", e)
1105        })?;
1106    Ok(())
1107}
1108
1109async fn handle_email_verification(
1110    conn: &mut PgConnection,
1111    user: &headless_lms_models::users::User,
1112) -> ControllerResult<web::Json<LoginResponse>> {
1113    let code = user_email_codes::generate_code();
1114
1115    let email_verification_token =
1116        email_verification_tokens::create_email_verification_token(conn, user.id, code.clone())
1117            .await
1118            .map_err(|e| {
1119                ControllerError::new(
1120                    ControllerErrorType::InternalServerError,
1121                    "Failed to create email verification token".to_string(),
1122                    Some(anyhow!(e)),
1123                )
1124            })?;
1125
1126    user_email_codes::insert_user_email_code(
1127        conn,
1128        user.id,
1129        UserEmailCodePurpose::AdminLogin,
1130        &code,
1131    )
1132    .await
1133    .map_err(|e| {
1134        ControllerError::new(
1135            ControllerErrorType::InternalServerError,
1136            "Failed to insert user email code".to_string(),
1137            Some(anyhow!(e)),
1138        )
1139    })?;
1140
1141    let email_template = models::email_templates::get_generic_email_template_by_type_and_language(
1142        conn,
1143        EmailTemplateType::ConfirmEmailCode,
1144        "en",
1145    )
1146    .await
1147    .map_err(|e| {
1148        ControllerError::new(
1149            ControllerErrorType::InternalServerError,
1150            format!("Failed to get email template: {}", e.message()),
1151            Some(anyhow!(e)),
1152        )
1153    })?;
1154
1155    models::email_deliveries::insert_email_delivery(conn, user.id, email_template.id)
1156        .await
1157        .map_err(|e| {
1158            ControllerError::new(
1159                ControllerErrorType::InternalServerError,
1160                "Failed to insert email delivery".to_string(),
1161                Some(anyhow!(e)),
1162            )
1163        })?;
1164
1165    email_verification_tokens::mark_code_sent(conn, &email_verification_token)
1166        .await
1167        .map_err(|e| {
1168            ControllerError::new(
1169                ControllerErrorType::InternalServerError,
1170                "Failed to mark code as sent".to_string(),
1171                Some(anyhow!(e)),
1172            )
1173        })?;
1174
1175    let token = skip_authorize();
1176    token.authorized_ok(web::Json(LoginResponse::RequiresEmailVerification {
1177        // Re-wrap for the wire boundary: redacted in Debug/logs, serialized once in the response.
1178        email_verification_token: OutboundSecret::new(
1179            email_verification_token.expose_secret().to_string(),
1180        ),
1181    }))
1182}
1183
1184#[derive(Debug, Deserialize, ToSchema)]
1185pub struct VerifyEmailRequest {
1186    #[schema(value_type = String)]
1187    pub email_verification_token: DbSecret,
1188    #[schema(value_type = String)]
1189    pub code: DbSecret,
1190}
1191
1192/**
1193POST `/api/v0/auth/verify-email` Verifies email verification code and completes login.
1194**/
1195#[utoipa::path(
1196    post,
1197    path = "/verify-email",
1198    tag = "auth",
1199    operation_id = "postAuthVerifyEmail",
1200    request_body = VerifyEmailRequest,
1201    responses(
1202        (status = 200, description = "Whether verification succeeded", body = bool)
1203    )
1204)]
1205#[instrument(skip(session, pool, payload))]
1206pub async fn verify_email(
1207    session: Session,
1208    pool: web::Data<PgPool>,
1209    payload: web::Json<VerifyEmailRequest>,
1210) -> ControllerResult<web::Json<bool>> {
1211    let mut conn = pool.acquire().await?;
1212    let payload = payload.into_inner();
1213
1214    let token = email_verification_tokens::get_by_email_verification_token(
1215        &mut conn,
1216        &payload.email_verification_token,
1217    )
1218    .await
1219    .map_err(|e| {
1220        ControllerError::new(
1221            ControllerErrorType::InternalServerError,
1222            "Failed to get email verification token".to_string(),
1223            Some(anyhow!(e)),
1224        )
1225    })?;
1226
1227    let Some(token_value) = token else {
1228        let skip_token = skip_authorize();
1229        return skip_token.authorized_ok(web::Json(false));
1230    };
1231
1232    let is_valid = email_verification_tokens::verify_code(
1233        &mut conn,
1234        &payload.email_verification_token,
1235        &payload.code,
1236    )
1237    .await
1238    .map_err(|e| {
1239        ControllerError::new(
1240            ControllerErrorType::InternalServerError,
1241            "Failed to verify code".to_string(),
1242            Some(anyhow!(e)),
1243        )
1244    })?;
1245
1246    if !is_valid {
1247        let skip_token = skip_authorize();
1248        return skip_token.authorized_ok(web::Json(false));
1249    }
1250
1251    let user_id = token_value.user_id;
1252
1253    user_email_codes::mark_user_email_code_used(
1254        &mut conn,
1255        user_id,
1256        UserEmailCodePurpose::AdminLogin,
1257        &payload.code,
1258    )
1259    .await
1260    .map_err(|e| {
1261        ControllerError::new(
1262            ControllerErrorType::InternalServerError,
1263            "Failed to mark user email code as used".to_string(),
1264            Some(anyhow!(e)),
1265        )
1266    })?;
1267
1268    email_verification_tokens::mark_as_used(&mut conn, &payload.email_verification_token)
1269        .await
1270        .map_err(|e| {
1271            ControllerError::new(
1272                ControllerErrorType::InternalServerError,
1273                "Failed to mark token as used".to_string(),
1274                Some(anyhow!(e)),
1275            )
1276        })?;
1277
1278    let user = models::users::get_by_id(&mut conn, user_id)
1279        .await
1280        .map_err(|e| {
1281            ControllerError::new(
1282                ControllerErrorType::InternalServerError,
1283                "Failed to get user".to_string(),
1284                Some(anyhow!(e)),
1285            )
1286        })?;
1287
1288    authentication::remember(&session, user)?;
1289
1290    let skip_token = skip_authorize();
1291    skip_token.authorized_ok(web::Json(true))
1292}
1293
1294#[derive(OpenApi)]
1295#[openapi(
1296    paths(
1297        signup,
1298        login,
1299        logout,
1300        logged_in,
1301        authorize_action_on_resource,
1302        authorize_multiple_actions_on_resources,
1303        user_info,
1304        send_delete_user_email_code,
1305        delete_user_account,
1306        verify_email,
1307    ),
1308    components(schemas(
1309        Login,
1310        LoginResponse,
1311        CreateAccountDetails,
1312        SignupResponse,
1313        UserInfo,
1314        headless_lms_authorization::ActionOnResource,
1315        headless_lms_authorization::Action,
1316        headless_lms_authorization::Resource,
1317        SendEmailCodeData,
1318        SendDeleteUserEmailCodeResult,
1319        EmailCode,
1320        DeleteUserAccountResult,
1321        VerifyEmailRequest,
1322        headless_lms_models::roles::UserRole,
1323    ))
1324)]
1325pub struct AuthRoutesApiDoc;
1326
1327pub fn _add_routes(cfg: &mut ServiceConfig) {
1328    cfg.service(
1329        web::resource("/signup")
1330            .wrap(RateLimit::new(RateLimitConfig {
1331                per_minute: Some(15),
1332                per_hour: None,
1333                per_day: Some(1000),
1334                per_month: None,
1335                ..Default::default()
1336            }))
1337            .to(signup),
1338    )
1339    .service(
1340        web::resource("/login")
1341            .wrap(RateLimit::new(RateLimitConfig {
1342                per_minute: Some(20),
1343                per_hour: Some(100),
1344                per_day: Some(500),
1345                per_month: None,
1346                ..Default::default()
1347            }))
1348            .to(login),
1349    )
1350    .route("/logout", web::post().to(logout))
1351    .route("/logged-in", web::get().to(logged_in))
1352    .route("/authorize", web::post().to(authorize_action_on_resource))
1353    .route(
1354        "/authorize-multiple",
1355        web::post().to(authorize_multiple_actions_on_resources),
1356    )
1357    .route("/user-info", web::get().to(user_info))
1358    .service(
1359        web::resource("/delete-user-account")
1360            .wrap(RateLimit::new(RateLimitConfig {
1361                per_minute: None,
1362                per_hour: Some(5),
1363                per_day: Some(10),
1364                per_month: None,
1365                ..Default::default()
1366            }))
1367            .to(delete_user_account),
1368    )
1369    .service(
1370        web::resource("/send-email-code")
1371            .wrap(RateLimit::new(RateLimitConfig {
1372                per_minute: None,
1373                per_hour: Some(5),
1374                per_day: Some(20),
1375                per_month: None,
1376                ..Default::default()
1377            }))
1378            .to(send_delete_user_email_code),
1379    )
1380    .service(
1381        web::resource("/verify-email")
1382            .wrap(RateLimit::new(RateLimitConfig {
1383                per_minute: Some(10),
1384                per_hour: Some(50),
1385                per_day: None,
1386                per_month: None,
1387                ..Default::default()
1388            }))
1389            .to(verify_email),
1390    );
1391}