1use crate::OAuthClient;
4use crate::config::server_runtime_config;
5use crate::domain::authorization::{AuthorizationToken, skip_authorize};
6use crate::prelude::*;
7use actix_http::Payload;
8use actix_session::Session;
9use actix_session::SessionExt;
10use actix_web::{FromRequest, HttpRequest};
11use anyhow::Result;
12use chrono::{DateTime, Duration, Utc};
13use futures::Future;
14use headless_lms_models::{self as models, users::User};
15use headless_lms_utils::http::REQWEST_CLIENT;
16use headless_lms_utils::services::tmc::TMCUser;
17use headless_lms_utils::services::tmc::TmcClient;
18use oauth2::EmptyExtraTokenFields;
19use oauth2::HttpClientError;
20use oauth2::RequestTokenError;
21use oauth2::ResourceOwnerPassword;
22use oauth2::ResourceOwnerUsername;
23use oauth2::StandardTokenResponse;
24use oauth2::TokenResponse;
25use oauth2::basic::BasicTokenType;
26use secrecy::ExposeSecret;
27use secrecy::SecretString;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use sqlx::PgConnection;
31use std::pin::Pin;
32use subtle::ConstantTimeEq;
33use tracing_log::log;
34use uuid::Uuid;
35
36const SESSION_KEY: &str = "user";
37
38const MOOCFI_GRAPHQL_URL: &str = "https://www.mooc.fi/api";
39
40fn constant_time_eq_str(left: &str, right: &str) -> bool {
41 left.as_bytes().ct_eq(right.as_bytes()).into()
42}
43#[derive(Debug, Serialize, Deserialize)]
44struct GraphQLRequest<'a> {
45 query: &'a str,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 variables: Option<serde_json::Value>,
48}
49
50#[derive(Debug, Serialize, Deserialize)]
51struct MoocfiUserResponse {
52 pub data: MoocfiUserResponseData,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56struct MoocfiUserResponseData {
57 pub user: MoocfiUserData,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61struct MoocfiUserData {
62 pub id: Uuid,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68pub struct AuthUser {
69 pub id: Uuid,
70 pub created_at: DateTime<Utc>,
71 pub updated_at: DateTime<Utc>,
72 pub deleted_at: Option<DateTime<Utc>>,
73 pub fetched_from_db_at: Option<DateTime<Utc>>,
74 upstream_id: Option<i32>,
75}
76
77impl AuthUser {
78 pub fn upstream_id(&self) -> Option<i32> {
80 self.upstream_id
81 }
82}
83
84impl FromRequest for AuthUser {
85 type Error = ControllerError;
86 type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
87
88 fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
89 let req = req.clone();
90 Box::pin(async move {
91 let req = req.clone();
92 let session = req.get_session();
93 let pool: Option<&web::Data<PgPool>> = req.app_data();
94 match session.get::<AuthUser>(SESSION_KEY) {
95 Ok(Some(user)) => Ok(verify_auth_user_exists(user, pool, &session).await?),
96 Ok(None) => Err(controller_err!(
97 Unauthorized,
98 "You are not currently logged in. Please sign in to continue.".to_string()
99 )),
100 Err(_) => {
101 session.remove(SESSION_KEY);
103 Err(controller_err!(
104 Unauthorized,
105 "Your session is invalid or has expired. Please sign in again.".to_string()
106 ))
107 }
108 }
109 })
110 }
111}
112
113async fn verify_auth_user_exists(
118 auth_user: AuthUser,
119 pool: Option<&web::Data<PgPool>>,
120 session: &Session,
121) -> Result<AuthUser, ControllerError> {
122 if let Some(fetched_from_db_at) = auth_user.fetched_from_db_at {
123 let time_now = Utc::now();
124 let time_hour_ago = time_now - Duration::hours(3);
125 if fetched_from_db_at > time_hour_ago {
126 return Ok(auth_user);
127 }
128 }
129 if let Some(pool) = pool {
130 info!("Checking whether the user saved in the session still exists in the database.");
131 let mut conn = pool.acquire().await?;
132 let user = models::users::get_by_id(&mut conn, auth_user.id).await?;
133 remember(session, user)?;
134 match session.get::<AuthUser>(SESSION_KEY) {
135 Ok(Some(session_user)) => Ok(session_user),
136 Ok(None) => Err(controller_err!(
137 InternalServerError,
138 "User did not persist in the session".to_string()
139 )),
140 Err(e) => Err(controller_err!(
141 InternalServerError,
142 "User did not persist in the session".to_string(),
143 e
144 )),
145 }
146 } else {
147 warn!("No database pool provided to verify_auth_user_exists");
148 Err(controller_err!(
149 InternalServerError,
150 "Unable to verify your user account. The database connection is unavailable."
151 .to_string()
152 ))
153 }
154}
155
156pub fn remember(session: &Session, user: models::users::User) -> Result<()> {
158 let auth_user = AuthUser {
159 id: user.id,
160 created_at: user.created_at,
161 updated_at: user.updated_at,
162 deleted_at: user.deleted_at,
163 upstream_id: user.upstream_id,
164 fetched_from_db_at: Some(Utc::now()),
165 };
166 session
167 .insert(SESSION_KEY, auth_user)
168 .map_err(|_| anyhow::anyhow!("Failed to insert to session"))
169}
170
171pub async fn has_auth_user_session(session: &Session, pool: web::Data<PgPool>) -> bool {
173 match session.get::<AuthUser>(SESSION_KEY) {
174 Ok(Some(sesssion_auth_user)) => {
175 verify_auth_user_exists(sesssion_auth_user, Some(&pool), session)
176 .await
177 .is_ok()
178 }
179 _ => false,
180 }
181}
182
183pub fn forget(session: &Session) {
185 session.purge();
186}
187
188pub fn handle_anonymous_token(req: &HttpRequest, user: Option<AuthUser>) -> Option<String> {
191 let anonymous_token_value = req
192 .headers()
193 .get("authorization")
194 .and_then(|anonymous_token| anonymous_token.to_str().ok()?.strip_prefix("Bearer "));
195
196 if let (Some(anonymous_token), None) = (anonymous_token_value, user) {
197 Some(anonymous_token.to_owned())
198 } else {
199 None
200 }
201}
202
203pub async fn authenticate_tmc_server(
206 request: &HttpRequest,
207) -> Result<AuthorizationToken, ControllerError> {
208 let tmc_server_secret_for_communicating_to_secret_project =
209 &server_runtime_config().tmc_server_secret_for_communicating_to_secret_project;
210 let auth_header = request
211 .headers()
212 .get("Authorization")
213 .ok_or_else(|| {
214 controller_err!(
215 Unauthorized,
216 "TMC server authorization failed: Missing Authorization header.".to_string()
217 )
218 })?
219 .to_str()
220 .map_err(|_| {
221 controller_err!(
222 Unauthorized,
223 "TMC server authorization failed: Invalid Authorization header format.".to_string()
224 )
225 })?;
226 if constant_time_eq_str(
227 auth_header,
228 tmc_server_secret_for_communicating_to_secret_project.expose_secret(),
229 ) {
230 return Ok(skip_authorize());
231 }
232 Err(controller_err!(
233 Unauthorized,
234 "TMC server authorization failed: Invalid authorization token.".to_string()
235 ))
236}
237
238pub fn parse_secret_key_from_header(header: &HttpRequest) -> Result<&str, ControllerError> {
239 let raw_token = header
240 .headers()
241 .get("Authorization")
242 .map_or(Ok(""), |x| x.to_str())
243 .map_err(|_| anyhow::anyhow!("Authorization header contains invalid characters."))?;
244 if !raw_token.starts_with("Basic") {
245 return Err(controller_err!(
246 Forbidden,
247 "Access denied: Authorization header must use Basic authentication format.".to_string()
248 ));
249 }
250 let secret_key = raw_token.split(' ').nth(1).ok_or_else(|| {
251 controller_err!(
252 Forbidden,
253 "Access denied: Malformed authorization token, expected 'Basic <token>' format."
254 .to_string()
255 )
256 })?;
257 Ok(secret_key)
258}
259
260pub async fn authenticate_tmc_mooc_fi_user(
262 conn: &mut PgConnection,
263 client: &OAuthClient,
264 email: String,
265 password: SecretString,
266 tmc_client: &TmcClient,
267) -> anyhow::Result<Option<(User, SecretString)>> {
268 info!("Attempting to authenticate user with TMC");
269 let token = match exchange_password_with_tmc(client, email.clone(), password).await? {
270 Some(token) => token,
271 None => return Ok(None),
272 };
273 debug!("Successfully obtained OAuth token from TMC");
274
275 let tmc_user = tmc_client
276 .get_user_from_tmc_mooc_fi_by_tmc_access_token(&token.clone())
277 .await?;
278 debug!(
279 "Creating or fetching user with TMC id {} and mooc.fi UUID {}",
280 tmc_user.id,
281 tmc_user
282 .courses_mooc_fi_user_id
283 .map(|uuid| uuid.to_string())
284 .unwrap_or_else(|| "None (will fetch from mooc.fi or generate new UUID)".to_string())
285 );
286 let user = get_or_create_user_from_tmc_mooc_fi_response(&mut *conn, tmc_user, &token).await?;
287 info!(
288 "Successfully got user details from mooc.fi for user {}",
289 user.id
290 );
291 info!("Successfully authenticated user {} with mooc.fi", user.id);
292 Ok(Some((user, token)))
293}
294
295pub type LoginToken = StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>;
296
297pub async fn exchange_password_with_tmc(
302 client: &OAuthClient,
303 email: String,
304 password: SecretString,
305) -> anyhow::Result<Option<SecretString>> {
306 let token_result = client
307 .exchange_password(
308 &ResourceOwnerUsername::new(email),
309 &ResourceOwnerPassword::new(password.expose_secret().to_string()),
311 )
312 .request_async(&async_http_client_with_headers)
313 .await;
314 match token_result {
315 Ok(token) => Ok(Some(SecretString::new(
316 token.access_token().secret().to_owned().into(),
317 ))),
318 Err(RequestTokenError::ServerResponse(server_response)) => {
319 let error = server_response.error();
320 let error_description = server_response.error_description();
321 let error_uri = server_response.error_uri();
322
323 if let oauth2::basic::BasicErrorResponseType::InvalidGrant = error {
325 warn!(
326 ?error_description,
327 ?error_uri,
328 "TMC did not accept the credentials: {}",
329 error
330 );
331 Ok(None)
332 } else {
333 error!(
334 ?error_description,
335 ?error_uri,
336 "TMC authentication error: {}",
337 error
338 );
339 Err(anyhow::anyhow!("Authentication error: {}", error))
340 }
341 }
342 Err(e) => {
343 error!("Failed to exchange password with TMC: {}", e);
344 Err(e.into())
345 }
346 }
347}
348
349async fn fetch_moocfi_id_by_upstream_id(
351 tmc_access_token: &SecretString,
352 upstream_id: i32,
353) -> anyhow::Result<Option<Uuid>> {
354 info!("Fetching mooc.fi UUID for upstream user id {}", upstream_id);
355
356 let res = REQWEST_CLIENT
357 .post(MOOCFI_GRAPHQL_URL)
358 .header(reqwest::header::CONTENT_TYPE, "application/json")
359 .header(reqwest::header::ACCEPT, "application/json")
360 .bearer_auth(tmc_access_token.expose_secret())
362 .json(&GraphQLRequest {
363 query: r#"
364query ($upstreamId: Int) {
365 user(upstream_id: $upstreamId) {
366 id
367 }
368}"#,
369 variables: Some(json!({ "upstreamId": upstream_id })),
370 })
371 .send()
372 .await;
373
374 match res {
375 Ok(response) => {
376 if !response.status().is_success() {
377 debug!(
378 "Failed to fetch mooc.fi user with status {}. Will generate new UUID instead.",
379 response.status()
380 );
381 return Ok(None);
382 }
383
384 match response.json::<MoocfiUserResponse>().await {
385 Ok(current_user_response) => {
386 info!(
387 "Successfully fetched mooc.fi UUID {} for upstream id {}",
388 current_user_response.data.user.id, upstream_id
389 );
390 Ok(Some(current_user_response.data.user.id))
391 }
392 Err(e) => {
393 debug!(
394 "Failed to parse mooc.fi response: {}. Will generate new UUID instead.",
395 e
396 );
397 Ok(None)
398 }
399 }
400 }
401 Err(e) => {
402 debug!(
403 "Failed to fetch from mooc.fi: {}. Will generate new UUID instead.",
404 e
405 );
406 Ok(None)
407 }
408 }
409}
410
411pub async fn get_or_create_user_from_tmc_mooc_fi_response(
412 conn: &mut PgConnection,
413 tmc_mooc_fi_user: TMCUser,
414 tmc_access_token: &SecretString,
415) -> anyhow::Result<User> {
416 let TMCUser {
417 id: upstream_id,
418 email,
419 courses_mooc_fi_user_id: moocfi_id,
420 user_field,
421 ..
422 } = tmc_mooc_fi_user;
423
424 let id = match moocfi_id {
425 Some(id) => id,
426 None => match fetch_moocfi_id_by_upstream_id(tmc_access_token, upstream_id).await? {
427 Some(fetched_id) => {
428 info!("Successfully fetched mooc.fi UUID {} for user", fetched_id);
429 fetched_id
430 }
431 None => {
432 info!("No mooc.fi UUID found, generating new UUID for user");
433 Uuid::new_v4()
434 }
435 },
436 };
437
438 let user = match models::users::find_by_upstream_id(conn, upstream_id).await? {
439 Some(existing_user) => existing_user,
440 None => {
441 let inserted = models::users::insert_with_upstream_id_and_moocfi_id(
442 conn,
443 &email,
444 user_field
445 .first_name
446 .as_deref()
447 .filter(|s| !s.trim().is_empty()),
448 user_field
449 .last_name
450 .as_deref()
451 .filter(|s| !s.trim().is_empty()),
452 upstream_id,
453 id,
454 )
455 .await;
456 match inserted {
457 Ok(user) => user,
458 Err(insert_error)
462 if matches!(
463 insert_error.error_type(),
464 models::ModelErrorType::DatabaseConstraint { constraint, .. }
465 if constraint == "users_upstream_id_active_uniq_idx"
466 ) =>
467 {
468 models::users::find_by_upstream_id(conn, upstream_id)
469 .await?
470 .ok_or(insert_error)?
471 }
472 Err(insert_error) => return Err(insert_error.into()),
473 }
474 }
475 };
476 Ok(user)
477}
478
479pub async fn authenticate_test_user(
481 conn: &mut PgConnection,
482 email: &str,
483 password: &SecretString,
484 application_configuration: &ApplicationConfiguration,
485) -> anyhow::Result<bool> {
486 assert!(application_configuration.test_mode);
488
489 let password = password.expose_secret();
491
492 let _user = if email == "admin@example.com" && password == "admin" {
493 models::users::get_by_email(conn, "admin@example.com").await?
494 } else if email == "teacher@example.com" && password == "teacher" {
495 models::users::get_by_email(conn, "teacher@example.com").await?
496 } else if email == "language.teacher@example.com" && password == "language.teacher" {
497 models::users::get_by_email(conn, "language.teacher@example.com").await?
498 } else if email == "material.viewer@example.com" && password == "material.viewer" {
499 models::users::get_by_email(conn, "material.viewer@example.com").await?
500 } else if email == "user@example.com" && password == "user" {
501 models::users::get_by_email(conn, "user@example.com").await?
502 } else if email == "assistant@example.com" && password == "assistant" {
503 models::users::get_by_email(conn, "assistant@example.com").await?
504 } else if email == "creator@example.com" && password == "creator" {
505 models::users::get_by_email(conn, "creator@example.com").await?
506 } else if email == "student1@example.com" && password == "student1" {
507 models::users::get_by_email(conn, "student1@example.com").await?
508 } else if email == "student2@example.com" && password == "student2" {
509 models::users::get_by_email(conn, "student2@example.com").await?
510 } else if email == "student3@example.com" && password == "student3" {
511 models::users::get_by_email(conn, "student3@example.com").await?
512 } else if email == "student4@example.com" && password == "student4" {
513 models::users::get_by_email(conn, "student4@example.com").await?
514 } else if email == "student5@example.com" && password == "student5" {
515 models::users::get_by_email(conn, "student5@example.com").await?
516 } else if email == "student6@example.com" && password == "student6" {
517 models::users::get_by_email(conn, "student6@example.com").await?
518 } else if email == "student7@example.com" && password == "student7" {
519 models::users::get_by_email(conn, "student7@example.com").await?
520 } else if email == "student8@example.com" && password == "student8" {
521 models::users::get_by_email(conn, "student8@example.com").await?
522 } else if email == "teaching-and-learning-services@example.com"
523 && password == "teaching-and-learning-services"
524 {
525 models::users::get_by_email(conn, "teaching-and-learning-services@example.com").await?
526 } else if email == "student-without-research-consent@example.com"
527 && password == "student-without-research-consent"
528 {
529 models::users::get_by_email(conn, "student-without-research-consent@example.com").await?
530 } else if email == "student-without-country@example.com"
531 && password == "student-without-country"
532 {
533 models::users::get_by_email(conn, "student-without-country@example.com").await?
534 } else if email == "langs@example.com" && password == "langs" {
535 models::users::get_by_email(conn, "langs@example.com").await?
536 } else if email == "sign-up-user@example.com" && password == "sign-up-user" {
537 models::users::get_by_email(conn, "sign-up-user@example.com").await?
538 } else {
539 info!("Authentication failed: incorrect test credentials");
540 return Ok(false);
541 };
542 info!("Successfully authenticated test user {}", email);
543 Ok(true)
544}
545
546pub async fn authenticate_test_token(
548 conn: &mut PgConnection,
549 token: &SecretString,
550 application_configuration: &ApplicationConfiguration,
551) -> anyhow::Result<Option<User>> {
552 assert!(application_configuration.test_mode);
554
555 let email = match token.expose_secret() {
558 "test-token-langs" => "langs@example.com",
559 "test-token-student1" => "student1@example.com",
560 "test-token-student2" => "student2@example.com",
561 _ => return Ok(None),
562 };
563 let user = models::users::get_by_email(conn, email).await?;
564 info!("Test mode: mapped fixed test token to seeded user {email}");
565 Ok(Some(user))
566}
567
568fn get_ratelimit_api_key() -> Result<reqwest::header::HeaderValue, HttpClientError<reqwest::Error>>
570{
571 let key = server_runtime_config()
572 .ratelimit_protection_safe_api_key
573 .clone();
574 debug!("Using ratelimit API key from runtime config");
575
576 key.expose_secret()
577 .parse::<reqwest::header::HeaderValue>()
578 .map_err(|err| {
579 error!("Invalid RATELIMIT API key format: {}", err);
580 HttpClientError::Other("Invalid RATELIMIT API key.".to_string())
581 })
582}
583
584async fn async_http_client_with_headers(
587 oauth_request: oauth2::HttpRequest,
588) -> Result<oauth2::HttpResponse, HttpClientError<reqwest::Error>> {
589 debug!("Making OAuth request to TMC server");
590
591 if log::log_enabled!(log::Level::Trace) {
592 if let Ok(url) = oauth_request.uri().to_string().parse::<reqwest::Url>() {
594 trace!("OAuth request path: {}", url.path());
595 }
596 }
597
598 let parsed_key = get_ratelimit_api_key()?;
599
600 debug!("Building request to TMC server");
601 let request = REQWEST_CLIENT
602 .request(
603 oauth_request.method().clone(),
604 oauth_request
605 .uri()
606 .to_string()
607 .parse::<reqwest::Url>()
608 .map_err(|e| HttpClientError::Other(format!("Invalid URL: {}", e)))?,
609 )
610 .headers(oauth_request.headers().clone())
611 .version(oauth_request.version())
612 .header("RATELIMIT-PROTECTION-SAFE-API-KEY", parsed_key)
613 .body(oauth_request.body().to_vec());
614
615 debug!("Sending request to TMC server");
616 let response = request
617 .send()
618 .await
619 .map_err(|e| HttpClientError::Other(format!("Failed to execute request: {}", e)))?;
620
621 debug!(
623 "Received response from TMC server - Status: {}, Version: {:?}",
624 response.status(),
625 response.version()
626 );
627
628 let status = response.status();
629 let version = response.version();
630 let headers = response.headers().clone();
631
632 debug!("Reading response body");
633 let body_bytes = response
634 .bytes()
635 .await
636 .map_err(|e| HttpClientError::Other(format!("Failed to read response body: {}", e)))?
637 .to_vec();
638
639 debug!("Building OAuth response");
640 let mut builder = oauth2::http::Response::builder()
641 .status(status)
642 .version(version);
643
644 if let Some(builder_headers) = builder.headers_mut() {
645 builder_headers.extend(headers.iter().map(|(k, v)| (k.clone(), v.clone())));
646 }
647
648 let oauth_response = builder
649 .body(body_bytes)
650 .map_err(|e| HttpClientError::Other(format!("Failed to construct response: {}", e)))?;
651
652 debug!("Successfully completed OAuth request");
653 Ok(oauth_response)
654}