1use 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#[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 let false_token = skip_authorize();
54 false_token.authorized_ok(web::Json(false))
55 }
56 }
57 } else {
58 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#[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 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 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#[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 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 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#[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 if app_conf.development_uuid_login {
323 return handle_uuid_login(&session, &mut conn, &email).await;
324 }
325
326 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 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 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 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 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#[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#[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#[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#[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#[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 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 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#[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 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 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 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}