headless_lms_server/controllers/
auth.rs

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