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