Skip to main content

headless_lms_server/domain/
authentication.rs

1//! Common functionality related to authenticating users.
2
3use 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// upstream_id is private so FromRequest is the only way to construct an AuthUser.
66/// Extractor for an authenticated user.
67#[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    /// The user's ID in TMC.
79    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 had an invalid value
102                    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
113/**
114 * Re-fetches the user from the database and refreshes the session once it is more than 3 hours
115 * old; otherwise returns the session's cached AuthUser unchanged.
116 */
117async 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
156/// Stores the user as authenticated in the given session.
157pub 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
171/// Checks if the user is authenticated in the given session.
172pub 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
183/// Forgets authentication from the current session, if any.
184pub fn forget(session: &Session) {
185    session.purge();
186}
187
188/// Returns the bearer token only when there is no authenticated user, for the anonymous
189/// chatbot-embed path; a logged-in request's token is never surfaced here.
190pub 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
203/// Checks the Authorization header against a secret from environment variables to verify the
204/// request originates from the TMC server.
205pub 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
260/// Authenticates the user with mooc.fi, returning the authenticated user and their oauth token.
261pub 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
297/// Exchanges user credentials with TMC for an OAuth token.
298///
299/// `Ok(None)` means the credentials were rejected; other failures (network, server errors) are
300/// `Err`.
301pub 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            // Exposed only here, at the OAuth2 client boundary.
310            &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            // InvalidGrant means the email or password was wrong.
324            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
349/// Fetches the mooc.fi UUID for a user by their upstream ID using the TMC access token.
350async 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        // Exposed only here, where the bearer token header is built.
361        .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                // A concurrent request can create the user between the find and the insert
459                // (the insert runs in a savepoint, so the connection stays usable). The unique
460                // index on upstream_id rejects the loser; return the winner's row instead.
461                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
479/// Authenticates a test user against predefined credentials.
480pub async fn authenticate_test_user(
481    conn: &mut PgConnection,
482    email: &str,
483    password: &SecretString,
484    application_configuration: &ApplicationConfiguration,
485) -> anyhow::Result<bool> {
486    // Sanity check to ensure this is not called outside of test mode. The whole application configuration is passed to this function instead of just the boolean to make mistakes harder.
487    assert!(application_configuration.test_mode);
488
489    // Test-only seeded credentials; exposed once here for the literal comparisons below.
490    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
546// Only used for testing, not to use in production.
547pub async fn authenticate_test_token(
548    conn: &mut PgConnection,
549    token: &SecretString,
550    application_configuration: &ApplicationConfiguration,
551) -> anyhow::Result<Option<User>> {
552    // Sanity check to ensure this is not called outside of test mode. The whole application configuration is passed to this function instead of just the boolean to make mistakes harder.
553    assert!(application_configuration.test_mode);
554
555    // These token strings are well-known constants, not secrets; they only work under
556    // `test_mode`.
557    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
568/// The rate-limit-bypass header value for requests to the TMC server.
569fn 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
584/// oauth2's HTTP transport, adapted to reqwest and tagged with the header that keeps TMC from
585/// rate-limiting the backend's own auth requests.
586async 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        // Only log the URL path, not query parameters which may contain credentials
593        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    // Log response status and version, but not headers or body which may contain tokens
622    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}