headless_lms_server/domain/
authorization.rs

1//! Common functionality related to authorization
2
3use crate::OAuthClient;
4use crate::prelude::*;
5use actix_http::Payload;
6use actix_session::Session;
7use actix_session::SessionExt;
8use actix_web::{FromRequest, HttpRequest, Responder};
9use anyhow::Result;
10use chrono::{DateTime, Duration, Utc};
11use futures::Future;
12use headless_lms_models::{self as models, roles::UserRole, users::User};
13use headless_lms_utils::http::REQWEST_CLIENT;
14use models::{CourseOrExamId, roles::Role};
15use oauth2::EmptyExtraTokenFields;
16use oauth2::HttpClientError;
17use oauth2::RequestTokenError;
18use oauth2::ResourceOwnerPassword;
19use oauth2::ResourceOwnerUsername;
20use oauth2::StandardTokenResponse;
21use oauth2::TokenResponse;
22use oauth2::basic::BasicTokenType;
23use serde::{Deserialize, Serialize};
24use serde_json::json;
25use sqlx::PgConnection;
26use std::env;
27use std::pin::Pin;
28use tracing_log::log;
29#[cfg(feature = "ts_rs")]
30pub use ts_rs::TS;
31use uuid::Uuid;
32
33const SESSION_KEY: &str = "user";
34
35const MOOCFI_GRAPHQL_URL: &str = "https://www.mooc.fi/api";
36
37// at least one field should be kept private to prevent initializing the struct outside of this module;
38// this way FromRequest is the only way to create an AuthUser
39/// Extractor for an authenticated user.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub struct AuthUser {
42    pub id: Uuid,
43    pub created_at: DateTime<Utc>,
44    pub updated_at: DateTime<Utc>,
45    pub deleted_at: Option<DateTime<Utc>>,
46    pub fetched_from_db_at: Option<DateTime<Utc>>,
47    upstream_id: Option<i32>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[cfg_attr(feature = "ts_rs", derive(TS))]
52#[serde(rename_all = "snake_case")]
53pub struct ActionOnResource {
54    pub action: Action,
55    pub resource: Resource,
56}
57
58impl AuthUser {
59    /// The user's ID in TMC.
60    pub fn upstream_id(&self) -> Option<i32> {
61        self.upstream_id
62    }
63}
64
65impl FromRequest for AuthUser {
66    type Error = ControllerError;
67    type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
68
69    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
70        let req = req.clone();
71        Box::pin(async move {
72            let req = req.clone();
73            let session = req.get_session();
74            let pool: Option<&web::Data<PgPool>> = req.app_data();
75            match session.get::<AuthUser>(SESSION_KEY) {
76                Ok(Some(user)) => Ok(verify_auth_user_exists(user, pool, &session).await?),
77                Ok(None) => Err(ControllerError::new(
78                    ControllerErrorType::Unauthorized,
79                    "You are not currently logged in. Please sign in to continue.".to_string(),
80                    None,
81                )),
82                Err(_) => {
83                    // session had an invalid value
84                    session.remove(SESSION_KEY);
85                    Err(ControllerError::new(
86                        ControllerErrorType::Unauthorized,
87                        "Your session is invalid or has expired. Please sign in again.".to_string(),
88                        None,
89                    ))
90                }
91            }
92        })
93    }
94}
95
96/**
97 * For making sure the user saved in the session still exists in the database. Check the user's existance when the session is at least 3 hours old, updates the session automatically, and returns an up-to-date AuthUser.
98 */
99async fn verify_auth_user_exists(
100    auth_user: AuthUser,
101    pool: Option<&web::Data<PgPool>>,
102    session: &Session,
103) -> Result<AuthUser, ControllerError> {
104    if let Some(fetched_from_db_at) = auth_user.fetched_from_db_at {
105        let time_now = Utc::now();
106        let time_hour_ago = time_now - Duration::hours(3);
107        if fetched_from_db_at > time_hour_ago {
108            // No need to check for the auth user yet
109            return Ok(auth_user);
110        }
111    }
112    if let Some(pool) = pool {
113        info!("Checking whether the user saved in the session still exists in the database.");
114        let mut conn = pool.acquire().await?;
115        let user = models::users::get_by_id(&mut conn, auth_user.id).await?;
116        remember(session, user)?;
117        match session.get::<AuthUser>(SESSION_KEY) {
118            Ok(Some(session_user)) => Ok(session_user),
119            Ok(None) => Err(ControllerError::new(
120                ControllerErrorType::InternalServerError,
121                "User did not persist in the session".to_string(),
122                None,
123            )),
124            Err(e) => Err(ControllerError::new(
125                ControllerErrorType::InternalServerError,
126                "User did not persist in the session".to_string(),
127                Some(e.into()),
128            )),
129        }
130    } else {
131        warn!("No database pool provided to verify_auth_user_exists");
132        Err(ControllerError::new(
133            ControllerErrorType::InternalServerError,
134            "Unable to verify your user account. The database connection is unavailable."
135                .to_string(),
136            None,
137        ))
138    }
139}
140
141/// Stores the user as authenticated in the given session.
142pub fn remember(session: &Session, user: models::users::User) -> Result<()> {
143    let auth_user = AuthUser {
144        id: user.id,
145        created_at: user.created_at,
146        updated_at: user.updated_at,
147        deleted_at: user.deleted_at,
148        upstream_id: user.upstream_id,
149        fetched_from_db_at: Some(Utc::now()),
150    };
151    session
152        .insert(SESSION_KEY, auth_user)
153        .map_err(|_| anyhow::anyhow!("Failed to insert to session"))
154}
155
156/// Checks if the user is authenticated in the given session.
157pub async fn has_auth_user_session(session: &Session, pool: web::Data<PgPool>) -> bool {
158    match session.get::<AuthUser>(SESSION_KEY) {
159        Ok(Some(sesssion_auth_user)) => {
160            verify_auth_user_exists(sesssion_auth_user, Some(&pool), session)
161                .await
162                .is_ok()
163        }
164        _ => false,
165    }
166}
167
168/// Forgets authentication from the current session, if any.
169pub fn forget(session: &Session) {
170    session.purge();
171}
172
173/// Describes an action that a user can take on some resource.
174#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
175#[cfg_attr(feature = "ts_rs", derive(TS))]
176#[serde(rename_all = "snake_case", tag = "type", content = "variant")]
177pub enum Action {
178    ViewMaterial,
179    View,
180    Edit,
181    Grade,
182    Teach,
183    Download,
184    Duplicate,
185    DeleteAnswer,
186    EditRole(UserRole),
187    CreateCoursesOrExams,
188    /// Deletion that we usually don't want to allow.
189    UsuallyUnacceptableDeletion,
190    UploadFile,
191    ViewUserProgressOrDetails,
192    ViewInternalCourseStructure,
193    ViewStats,
194}
195
196/// The target of an action.
197#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
198#[cfg_attr(feature = "ts_rs", derive(TS))]
199#[serde(rename_all = "snake_case", tag = "type", content = "id")]
200pub enum Resource {
201    GlobalPermissions,
202    Chapter(Uuid),
203    Course(Uuid),
204    CourseInstance(Uuid),
205    Exam(Uuid),
206    Exercise(Uuid),
207    ExerciseSlideSubmission(Uuid),
208    ExerciseTask(Uuid),
209    ExerciseTaskGrading(Uuid),
210    ExerciseTaskSubmission(Uuid),
211    Organization(Uuid),
212    Page(Uuid),
213    StudyRegistry(String),
214    AnyCourse,
215    Role,
216    User,
217    PlaygroundExample,
218    ExerciseService,
219}
220
221impl Resource {
222    pub fn from_course_or_exam_id(course_or_exam_id: CourseOrExamId) -> Self {
223        match course_or_exam_id {
224            CourseOrExamId::Course(id) => Self::Course(id),
225            CourseOrExamId::Exam(id) => Self::Exam(id),
226        }
227    }
228}
229
230/// Validates that user has right to function
231#[derive(Copy, Clone, Debug)]
232pub struct AuthorizationToken(());
233
234impl AuthorizationToken {
235    pub fn authorized_ok<T>(self, t: T) -> ControllerResult<T> {
236        Ok(AuthorizedResponse {
237            data: t,
238            token: self,
239        })
240    }
241}
242
243/// Responder for AuthorizationToken
244#[derive(Copy, Clone)]
245pub struct AuthorizedResponse<T> {
246    pub data: T,
247    pub token: AuthorizationToken,
248}
249
250impl<T: Responder> Responder for AuthorizedResponse<T> {
251    type Body = T::Body;
252
253    fn respond_to(self, req: &HttpRequest) -> actix_web::HttpResponse<Self::Body> {
254        T::respond_to(self.data, req)
255    }
256}
257
258/**  Skips the authorize() and returns AuthorizationToken, needed in functions with anonymous and test users
259
260# Example
261
262```ignore
263async fn example_function(
264    // No user mentioned
265) -> ControllerResult<....> {
266    // We need to return ControllerResult -> AuthorizedResponse
267
268    let token = skip_authorize();
269
270    token.authorized_ok(web::Json(organizations))
271
272}
273```
274*/
275pub fn skip_authorize() -> AuthorizationToken {
276    AuthorizationToken(())
277}
278
279/**  Can be used to check whether user is allowed to view some course material */
280pub async fn authorize_access_to_course_material(
281    conn: &mut PgConnection,
282    user_id: Option<Uuid>,
283    course_id: Uuid,
284) -> Result<AuthorizationToken, ControllerError> {
285    let token = if models::courses::is_draft(conn, course_id).await? {
286        info!("Course is in draft mode");
287        if user_id.is_none() {
288            return Err(ControllerError::new(
289                ControllerErrorType::Unauthorized,
290                "This course is currently in draft mode and not publicly available. Please log in if you have access permissions.".to_string(),
291                None,
292            ));
293        }
294        authorize(conn, Act::ViewMaterial, user_id, Res::Course(course_id)).await?
295    } else if models::courses::is_joinable_by_code_only(conn, course_id).await? {
296        info!("Course is joinable by code only");
297        if models::join_code_uses::check_if_user_has_access_to_course(
298            conn,
299            user_id.unwrap(),
300            course_id,
301        )
302        .await
303        .is_err()
304        {
305            authorize(conn, Act::ViewMaterial, user_id, Res::Course(course_id)).await?;
306        }
307        skip_authorize()
308    } else {
309        // The course is publicly available, no need to authorize
310        skip_authorize()
311    };
312
313    Ok(token)
314}
315
316/**  Can be used to check whether user is allowed to view some course material */
317pub async fn authorize_access_to_tmc_server(
318    request: &HttpRequest,
319) -> Result<AuthorizationToken, ControllerError> {
320    let tmc_server_secret_for_communicating_to_secret_project =
321        env::var("TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT")
322            .expect("TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT must be defined");
323    // check authorization header
324    let auth_header = request
325        .headers()
326        .get("Authorization")
327        .ok_or_else(|| {
328            ControllerError::new(
329                ControllerErrorType::Unauthorized,
330                "TMC server authorization failed: Missing Authorization header.".to_string(),
331                None,
332            )
333        })?
334        .to_str()
335        .map_err(|_| {
336            ControllerError::new(
337                ControllerErrorType::Unauthorized,
338                "TMC server authorization failed: Invalid Authorization header format.".to_string(),
339                None,
340            )
341        })?;
342    // If auth header correct one, grant access
343    if auth_header == tmc_server_secret_for_communicating_to_secret_project {
344        return Ok(skip_authorize());
345    }
346    Err(ControllerError::new(
347        ControllerErrorType::Unauthorized,
348        "TMC server authorization failed: Invalid authorization token.".to_string(),
349        None,
350    ))
351}
352
353/**  Can be used to check whether user is allowed to view some course material. Chapters can be closed and and limited to certain people only. */
354pub async fn can_user_view_chapter(
355    conn: &mut PgConnection,
356    user_id: Option<Uuid>,
357    course_id: Option<Uuid>,
358    chapter_id: Option<Uuid>,
359) -> Result<bool, ControllerError> {
360    if let Some(course_id) = course_id {
361        if let Some(chapter_id) = chapter_id {
362            if !models::chapters::is_open(&mut *conn, chapter_id).await? {
363                if user_id.is_none() {
364                    return Ok(false);
365                }
366                // If the user has been granted access to view the material, then they can see the unopened chapters too
367                // This is important because sometimes teachers wish to test unopened chapters with real students
368                let permission =
369                    authorize(conn, Act::ViewMaterial, user_id, Res::Course(course_id)).await;
370
371                return Ok(permission.is_ok());
372            }
373        }
374    }
375    Ok(true)
376}
377
378/**
379The authorization token is the only way to return a controller result, and should only be used in controller functions that return a response to the user.
380
381
382let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Page(*page_id)).await?;
383
384token.authorized_ok(web::Json(cms_page_info))
385
386
387*/
388pub async fn authorize(
389    conn: &mut PgConnection,
390    action: Action,
391    user_id: Option<Uuid>,
392    resource: Resource,
393) -> Result<AuthorizationToken, ControllerError> {
394    let user_roles = if let Some(user_id) = user_id {
395        models::roles::get_roles(conn, user_id)
396            .await
397            .map_err(|original_err| {
398                ControllerError::new(
399                    ControllerErrorType::InternalServerError,
400                    format!("Failed to fetch user roles: {}", original_err),
401                    Some(original_err.into()),
402                )
403            })?
404    } else {
405        Vec::new()
406    };
407
408    authorize_with_fetched_list_of_roles(conn, action, user_id, resource, &user_roles).await
409}
410
411/// Creates a ControllerError for authorization failures with more information in the source error
412fn create_authorization_error(user_roles: &[Role], action: Option<Action>) -> ControllerError {
413    let mut detail_message = String::new();
414
415    if user_roles.is_empty() {
416        detail_message.push_str("You don't have any assigned roles.");
417    } else {
418        detail_message.push_str("Your current roles are: ");
419        let roles_str = user_roles
420            .iter()
421            .map(|r| format!("{:?} ({})", r.role, r.domain_description()))
422            .collect::<Vec<_>>()
423            .join(", ");
424        detail_message.push_str(&roles_str);
425    }
426
427    if let Some(act) = action {
428        detail_message.push_str(&format!("\nAction attempted: {:?}", act));
429    }
430
431    // Create the controller error
432    ControllerError::new(
433        ControllerErrorType::Forbidden,
434        "Unauthorized. Please contact course staff if you believe you should have access."
435            .to_string(),
436        Some(ControllerError::new(ControllerErrorType::Forbidden, detail_message, None).into()),
437    )
438}
439
440/// Same as `authorize`, but takes as an argument `Vec<Role>` so that we avoid fetching the roles from the database for optimization reasons. This is useful when we're checking multiple authorizations at once.
441pub async fn authorize_with_fetched_list_of_roles(
442    conn: &mut PgConnection,
443    action: Action,
444    _user_id: Option<Uuid>,
445    resource: Resource,
446    user_roles: &[Role],
447) -> Result<AuthorizationToken, ControllerError> {
448    // check global role
449    for role in user_roles {
450        if role.is_global() && has_permission(role.role, action) {
451            return Ok(AuthorizationToken(()));
452        }
453    }
454
455    // for this resource, the domain of the role does not matter (e.g. organization role, course role, etc.)
456    if resource == Resource::AnyCourse {
457        for role in user_roles {
458            if has_permission(role.role, action) {
459                return Ok(AuthorizationToken(()));
460            }
461        }
462    }
463
464    // for some resources, we need to get more information from the database
465    match resource {
466        Resource::Chapter(id) => {
467            // if trying to View a chapter that is not open, check for permission to view the material
468            let action =
469                if matches!(action, Action::View) && !models::chapters::is_open(conn, id).await? {
470                    Action::ViewMaterial
471                } else {
472                    action
473                };
474            // there are no chapter roles so we check the course instead
475            let course_id = models::chapters::get_course_id(conn, id).await?;
476            check_course_permission(conn, user_roles, action, course_id).await
477        }
478        Resource::Course(id) => check_course_permission(conn, user_roles, action, id).await,
479        Resource::CourseInstance(id) => {
480            check_course_instance_permission(conn, user_roles, action, id).await
481        }
482        Resource::Exercise(id) => {
483            // an exercise can be part of a course or an exam
484            let course_or_exam_id = models::exercises::get_course_or_exam_id(conn, id).await?;
485            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
486        }
487        Resource::ExerciseSlideSubmission(id) => {
488            //an exercise slide submissions can be part of a course or an exam
489            let course_or_exam_id =
490                models::exercise_slide_submissions::get_course_and_exam_id(conn, id).await?;
491            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
492        }
493        Resource::ExerciseTask(id) => {
494            // an exercise task can be part of a course or an exam
495            let course_or_exam_id = models::exercise_tasks::get_course_or_exam_id(conn, id).await?;
496            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
497        }
498        Resource::ExerciseTaskSubmission(id) => {
499            // an exercise task submission can be part of a course or an exam
500            let course_or_exam_id =
501                models::exercise_task_submissions::get_course_and_exam_id(conn, id).await?;
502            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
503        }
504        Resource::ExerciseTaskGrading(id) => {
505            // a grading can be part of a course or an exam
506            let course_or_exam_id =
507                models::exercise_task_gradings::get_course_or_exam_id(conn, id).await?;
508            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
509        }
510        Resource::Organization(id) => check_organization_permission(user_roles, action, id).await,
511        Resource::Page(id) => {
512            // a page can be part of a course or an exam
513            let course_or_exam_id = models::pages::get_course_and_exam_id(conn, id).await?;
514            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
515        }
516        Resource::StudyRegistry(secret_key) => {
517            check_study_registry_permission(conn, secret_key, action).await
518        }
519        Resource::Exam(exam_id) => check_exam_permission(conn, user_roles, action, exam_id).await,
520        Resource::Role
521        | Resource::User
522        | Resource::AnyCourse
523        | Resource::PlaygroundExample
524        | Resource::ExerciseService
525        | Resource::GlobalPermissions => {
526            // permissions for these resources have already been checked
527            Err(create_authorization_error(user_roles, Some(action)))
528        }
529    }
530}
531
532async fn check_organization_permission(
533    roles: &[Role],
534    action: Action,
535    organization_id: Uuid,
536) -> Result<AuthorizationToken, ControllerError> {
537    if action == Action::View {
538        // anyone can view an organization regardless of roles
539        return Ok(AuthorizationToken(()));
540    };
541
542    // check organization role
543    for role in roles {
544        if role.is_role_for_organization(organization_id) && has_permission(role.role, action) {
545            return Ok(AuthorizationToken(()));
546        }
547    }
548    Err(create_authorization_error(roles, Some(action)))
549}
550
551/// Also checks organization role which is valid for courses.
552async fn check_course_permission(
553    conn: &mut PgConnection,
554    roles: &[Role],
555    action: Action,
556    course_id: Uuid,
557) -> Result<AuthorizationToken, ControllerError> {
558    // check course role
559    for role in roles {
560        if role.is_role_for_course(course_id) && has_permission(role.role, action) {
561            return Ok(AuthorizationToken(()));
562        }
563    }
564    let organization_id = models::courses::get_organization_id(conn, course_id).await?;
565    check_organization_permission(roles, action, organization_id).await
566}
567
568/// Also checks organization and course roles which are valid for course instances.
569async fn check_course_instance_permission(
570    conn: &mut PgConnection,
571    roles: &[Role],
572    mut action: Action,
573    course_instance_id: Uuid,
574) -> Result<AuthorizationToken, ControllerError> {
575    // if trying to View a course instance that is not open, we check for permission to Teach
576    if action == Action::View
577        && !models::course_instances::is_open(conn, course_instance_id).await?
578    {
579        action = Action::Teach;
580    }
581
582    // check course instance role
583    for role in roles {
584        if role.is_role_for_course_instance(course_instance_id) && has_permission(role.role, action)
585        {
586            return Ok(AuthorizationToken(()));
587        }
588    }
589    let course_id = models::course_instances::get_course_id(conn, course_instance_id).await?;
590    check_course_permission(conn, roles, action, course_id).await
591}
592
593/// Also checks organization role which is valid for exams.
594async fn check_exam_permission(
595    conn: &mut PgConnection,
596    roles: &[Role],
597    action: Action,
598    exam_id: Uuid,
599) -> Result<AuthorizationToken, ControllerError> {
600    // check exam role
601    for role in roles {
602        if role.is_role_for_exam(exam_id) && has_permission(role.role, action) {
603            return Ok(AuthorizationToken(()));
604        }
605    }
606    let organization_id = models::exams::get_organization_id(conn, exam_id).await?;
607    check_organization_permission(roles, action, organization_id).await
608}
609
610async fn check_course_or_exam_permission(
611    conn: &mut PgConnection,
612    roles: &[Role],
613    action: Action,
614    course_or_exam_id: CourseOrExamId,
615) -> Result<AuthorizationToken, ControllerError> {
616    match course_or_exam_id {
617        CourseOrExamId::Course(course_id) => {
618            check_course_permission(conn, roles, action, course_id).await
619        }
620        CourseOrExamId::Exam(exam_id) => check_exam_permission(conn, roles, action, exam_id).await,
621    }
622}
623
624async fn check_study_registry_permission(
625    conn: &mut PgConnection,
626    secret_key: String,
627    action: Action,
628) -> Result<AuthorizationToken, ControllerError> {
629    let _registrar = models::study_registry_registrars::get_by_secret_key(conn, &secret_key)
630        .await
631        .map_err(|original_error| {
632            ControllerError::new(
633                ControllerErrorType::Forbidden,
634                format!("Study registry access denied: Invalid or missing secret key. The operation {:?} cannot be performed.", action),
635                Some(original_error.into()),
636            )
637        })?;
638    Ok(AuthorizationToken(()))
639}
640
641// checks whether the role is allowed to perform the action
642fn has_permission(user_role: UserRole, action: Action) -> bool {
643    use Action::*;
644    use UserRole::*;
645
646    match user_role {
647        Admin => true,
648        Teacher => matches!(
649            action,
650            View | Teach
651                | Edit
652                | Grade
653                | Duplicate
654                | DeleteAnswer
655                | EditRole(Teacher | Assistant | Reviewer | MaterialViewer | StatsViewer)
656                | CreateCoursesOrExams
657                | ViewMaterial
658                | UploadFile
659                | ViewUserProgressOrDetails
660                | ViewInternalCourseStructure
661                | ViewStats
662        ),
663        Assistant => matches!(
664            action,
665            View | Edit
666                | Grade
667                | DeleteAnswer
668                | EditRole(Assistant | Reviewer | MaterialViewer)
669                | Teach
670                | ViewMaterial
671                | ViewUserProgressOrDetails
672                | ViewInternalCourseStructure
673        ),
674        Reviewer => matches!(
675            action,
676            View | Grade | ViewMaterial | ViewInternalCourseStructure
677        ),
678        CourseOrExamCreator => matches!(action, CreateCoursesOrExams),
679        MaterialViewer => matches!(action, ViewMaterial),
680        TeachingAndLearningServices => {
681            matches!(
682                action,
683                View | ViewMaterial
684                    | ViewUserProgressOrDetails
685                    | ViewInternalCourseStructure
686                    | ViewStats
687            )
688        }
689        StatsViewer => matches!(action, ViewStats),
690    }
691}
692
693pub fn parse_secret_key_from_header(header: &HttpRequest) -> Result<&str, ControllerError> {
694    let raw_token = header
695        .headers()
696        .get("Authorization")
697        .map_or(Ok(""), |x| x.to_str())
698        .map_err(|_| anyhow::anyhow!("Authorization header contains invalid characters."))?;
699    if !raw_token.starts_with("Basic") {
700        return Err(ControllerError::new(
701            ControllerErrorType::Forbidden,
702            "Access denied: Authorization header must use Basic authentication format.".to_string(),
703            None,
704        ));
705    }
706    let secret_key = raw_token.split(' ').nth(1).ok_or_else(|| {
707        ControllerError::new(
708            ControllerErrorType::Forbidden,
709            "Access denied: Malformed authorization token, expected 'Basic <token>' format."
710                .to_string(),
711            None,
712        )
713    })?;
714    Ok(secret_key)
715}
716
717/// Authenticates the user with mooc.fi, returning the authenticated user and their oauth token.
718pub async fn authenticate_moocfi_user(
719    conn: &mut PgConnection,
720    client: &OAuthClient,
721    email: String,
722    password: String,
723) -> anyhow::Result<Option<(User, LoginToken)>> {
724    info!("Attempting to authenticate user with TMC");
725    let token = match exchange_password_with_tmc(client, email.clone(), password).await? {
726        Some(token) => token,
727        None => return Ok(None),
728    };
729    debug!("Successfully obtained OAuth token from TMC");
730    let user = get_user_from_moocfi_by_login_token(&token, conn).await?;
731    info!("Successfully authenticated user {} with mooc.fi", user.id);
732    Ok(Some((user, token)))
733}
734
735pub type LoginToken = StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>;
736
737/**
738Exchanges user credentials with TMC server to obtain an OAuth token.
739
740This function attempts to authenticate a user with the TMC server using their email and password.
741It returns different results based on the authentication outcome:
742
743- `Ok(Some(token))` - Authentication successful, returns the OAuth token
744- `Ok(None)` - Authentication failed due to invalid credentials (email/password)
745- `Err(...)` - Authentication failed due to other errors (server issues, network problems, etc.)
746*/
747pub async fn exchange_password_with_tmc(
748    client: &OAuthClient,
749    email: String,
750    password: String,
751) -> anyhow::Result<Option<LoginToken>> {
752    let token_result = client
753        .exchange_password(
754            &ResourceOwnerUsername::new(email),
755            &ResourceOwnerPassword::new(password),
756        )
757        .request_async(&async_http_client_with_headers)
758        .await;
759    match token_result {
760        Ok(token) => Ok(Some(token)),
761        Err(RequestTokenError::ServerResponse(server_response)) => {
762            let error = server_response.error();
763            let error_description = server_response.error_description();
764            let error_uri = server_response.error_uri();
765
766            // Only return Ok(None) for InvalidGrant errors (wrong email/password)
767            if let oauth2::basic::BasicErrorResponseType::InvalidGrant = error {
768                warn!(
769                    ?error_description,
770                    ?error_uri,
771                    "TMC did not accept the credentials: {}",
772                    error
773                );
774                Ok(None)
775            } else {
776                // For all other error types, return an error
777                error!(
778                    ?error_description,
779                    ?error_uri,
780                    "TMC authentication error: {}",
781                    error
782                );
783                Err(anyhow::anyhow!("Authentication error: {}", error))
784            }
785        }
786        Err(e) => {
787            error!("Failed to exchange password with TMC: {}", e);
788            Err(e.into())
789        }
790    }
791}
792
793#[derive(Debug, Serialize, Deserialize)]
794struct GraphQLRequest<'a> {
795    query: &'a str,
796    #[serde(skip_serializing_if = "Option::is_none")]
797    variables: Option<serde_json::Value>,
798}
799
800#[derive(Debug, Serialize, Deserialize)]
801struct MoocfiUserResponse {
802    pub data: MoocfiUserResponseData,
803}
804
805#[derive(Debug, Serialize, Deserialize)]
806struct MoocfiUserResponseData {
807    pub user: MoocfiUser,
808}
809
810#[derive(Debug, Serialize, Deserialize)]
811struct MoocfiUser {
812    pub id: Uuid,
813    pub first_name: Option<String>,
814    pub last_name: Option<String>,
815    pub email: String,
816    pub upstream_id: i32,
817}
818
819pub async fn get_user_from_moocfi_by_login_token(
820    token: &LoginToken,
821    conn: &mut PgConnection,
822) -> anyhow::Result<User> {
823    info!("Getting user details from mooc.fi");
824
825    let res = REQWEST_CLIENT
826        .post(MOOCFI_GRAPHQL_URL)
827        .header(reqwest::header::CONTENT_TYPE, "application/json")
828        .header(reqwest::header::ACCEPT, "application/json")
829        .json(&GraphQLRequest {
830            query: r#"
831{
832    user: currentUser {
833      id
834      email
835      first_name
836      last_name
837      upstream_id
838    }
839}"#,
840            variables: None,
841        })
842        .bearer_auth(token.access_token().secret())
843        .send()
844        .await
845        .context("Failed to send request to Mooc.fi")?;
846
847    if !res.status().is_success() {
848        error!(
849            "Failed to get user from mooc.fi with status {}",
850            res.status()
851        );
852        return Err(anyhow::anyhow!("Failed to get current user from Mooc.fi"));
853    }
854
855    debug!("Received response from mooc.fi, parsing user data");
856    let current_user_response: MoocfiUserResponse = res
857        .json()
858        .await
859        .context("Unexpected response from Mooc.fi")?;
860
861    debug!(
862        "Creating or fetching user with mooc.fi id {}",
863        current_user_response.data.user.id
864    );
865    let user = get_or_create_user_from_moocfi_response(&mut *conn, current_user_response.data.user)
866        .await?;
867    info!(
868        "Successfully got user details from mooc.fi for user {}",
869        user.id
870    );
871
872    Ok(user)
873}
874
875pub async fn get_user_from_moocfi_by_tmc_access_token_and_upstream_id(
876    conn: &mut PgConnection,
877    tmc_access_token: &str,
878    upstream_id: &i32,
879) -> anyhow::Result<User> {
880    info!("Getting user details from mooc.fi");
881    let client = reqwest::Client::default();
882
883    let res = client
884        .post(MOOCFI_GRAPHQL_URL)
885        .header(reqwest::header::CONTENT_TYPE, "application/json")
886        .header(reqwest::header::ACCEPT, "application/json")
887        .bearer_auth(tmc_access_token)
888        .json(&GraphQLRequest {
889            query: r#"
890query ($upstreamId: Int) {
891  user(upstream_id: $upstreamId) {
892    id
893    email
894    first_name
895    last_name
896    upstream_id
897  }
898}"#,
899            variables: Some(json!({ "upstreamId": upstream_id })),
900        })
901        .send()
902        .await
903        .context("Failed to send request to Mooc.fi")?;
904    if !res.status().is_success() {
905        return Err(anyhow::anyhow!("Failed to get current user from Mooc.fi"));
906    }
907    let current_user_response: MoocfiUserResponse = res
908        .json()
909        .await
910        .context("Unexpected response from Mooc.fi")?;
911
912    let user = get_or_create_user_from_moocfi_response(&mut *conn, current_user_response.data.user)
913        .await?;
914    Ok(user)
915}
916
917async fn get_or_create_user_from_moocfi_response(
918    conn: &mut PgConnection,
919    moocfi_user: MoocfiUser,
920) -> anyhow::Result<User> {
921    let MoocfiUser {
922        id: moocfi_id,
923        first_name,
924        last_name,
925        email,
926        upstream_id,
927    } = moocfi_user;
928
929    // fetch existing user or create new one
930    let user = match models::users::find_by_upstream_id(conn, upstream_id).await? {
931        Some(existing_user) => existing_user,
932        None => {
933            models::users::insert_with_upstream_id_and_moocfi_id(
934                conn,
935                &email,
936                // convert empty names to None
937                first_name
938                    .as_deref()
939                    .and_then(|n| if n.trim().is_empty() { None } else { Some(n) }),
940                last_name
941                    .as_deref()
942                    .and_then(|n| if n.trim().is_empty() { None } else { Some(n) }),
943                upstream_id,
944                moocfi_id,
945            )
946            .await?
947        }
948    };
949    Ok(user)
950}
951
952/// Authenticates a test user with predefined credentials.
953/// Returns Ok(true) if authentication succeeds, Ok(false) if credentials are incorrect,
954/// and Err for other errors.
955pub async fn authenticate_test_user(
956    conn: &mut PgConnection,
957    email: &str,
958    password: &str,
959    application_configuration: &ApplicationConfiguration,
960) -> anyhow::Result<bool> {
961    // 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.
962    assert!(application_configuration.test_mode);
963
964    let _user = if email == "admin@example.com" && password == "admin" {
965        models::users::get_by_email(conn, "admin@example.com").await?
966    } else if email == "teacher@example.com" && password == "teacher" {
967        models::users::get_by_email(conn, "teacher@example.com").await?
968    } else if email == "language.teacher@example.com" && password == "language.teacher" {
969        models::users::get_by_email(conn, "language.teacher@example.com").await?
970    } else if email == "material.viewer@example.com" && password == "material.viewer" {
971        models::users::get_by_email(conn, "material.viewer@example.com").await?
972    } else if email == "user@example.com" && password == "user" {
973        models::users::get_by_email(conn, "user@example.com").await?
974    } else if email == "assistant@example.com" && password == "assistant" {
975        models::users::get_by_email(conn, "assistant@example.com").await?
976    } else if email == "creator@example.com" && password == "creator" {
977        models::users::get_by_email(conn, "creator@example.com").await?
978    } else if email == "student1@example.com" && password == "student1" {
979        models::users::get_by_email(conn, "student1@example.com").await?
980    } else if email == "student2@example.com" && password == "student2" {
981        models::users::get_by_email(conn, "student2@example.com").await?
982    } else if email == "student3@example.com" && password == "student3" {
983        models::users::get_by_email(conn, "student3@example.com").await?
984    } else if email == "student4@example.com" && password == "student4" {
985        models::users::get_by_email(conn, "student4@example.com").await?
986    } else if email == "student5@example.com" && password == "student5" {
987        models::users::get_by_email(conn, "student5@example.com").await?
988    } else if email == "teaching-and-learning-services@example.com"
989        && password == "teaching-and-learning-services"
990    {
991        models::users::get_by_email(conn, "teaching-and-learning-services@example.com").await?
992    } else if email == "student-without-research-consent@example.com"
993        && password == "student-without-research-consent"
994    {
995        models::users::get_by_email(conn, "student-without-research-consent@example.com").await?
996    } else if email == "student-without-country@example.com"
997        && password == "student-without-country"
998    {
999        models::users::get_by_email(conn, "student-without-country@example.com").await?
1000    } else if email == "langs@example.com" && password == "langs" {
1001        models::users::get_by_email(conn, "langs@example.com").await?
1002    } else {
1003        info!("Authentication failed: incorrect test credentials");
1004        return Ok(false);
1005    };
1006    info!("Successfully authenticated test user {}", email);
1007    Ok(true)
1008}
1009
1010// Only used for testing, not to use in production.
1011pub async fn authenticate_test_token(
1012    conn: &mut PgConnection,
1013    token: &str,
1014    application_configuration: &ApplicationConfiguration,
1015) -> anyhow::Result<User> {
1016    // 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.
1017    assert!(application_configuration.test_mode);
1018    let user = models::users::get_by_email(conn, token).await?;
1019    Ok(user)
1020}
1021
1022/**
1023 Gets the rate limit protection API key from environment variables and converts it to a header value.
1024 This key is used to bypass rate limiting when making requests to TMC server.
1025*/
1026fn get_ratelimit_api_key() -> Result<reqwest::header::HeaderValue, HttpClientError<reqwest::Error>>
1027{
1028    let key = match std::env::var("RATELIMIT_PROTECTION_SAFE_API_KEY") {
1029        Ok(key) => {
1030            debug!("Found RATELIMIT_PROTECTION_SAFE_API_KEY");
1031            key
1032        }
1033        Err(e) => {
1034            error!(
1035                "RATELIMIT_PROTECTION_SAFE_API_KEY environment variable not set: {}",
1036                e
1037            );
1038            return Err(HttpClientError::Other(
1039                "RATELIMIT_PROTECTION_SAFE_API_KEY must be defined".to_string(),
1040            ));
1041        }
1042    };
1043
1044    key.parse::<reqwest::header::HeaderValue>().map_err(|err| {
1045        error!("Invalid RATELIMIT API key format: {}", err);
1046        HttpClientError::Other("Invalid RATELIMIT API key.".to_string())
1047    })
1048}
1049
1050/**
1051 HTTP Client used only for authenticating with TMC server. This function:
1052 1. Ensures TMC server does not rate limit auth requests from backend by adding a special header
1053 2. Converts between oauth2 crate's internal http types and our reqwest types:
1054    - Converts oauth2::HttpRequest to a reqwest::Request
1055    - Makes the request using our REQWEST_CLIENT
1056    - Converts the reqwest::Response back to oauth2::HttpResponse
1057*/
1058async fn async_http_client_with_headers(
1059    oauth_request: oauth2::HttpRequest,
1060) -> Result<oauth2::HttpResponse, HttpClientError<reqwest::Error>> {
1061    debug!("Making OAuth request to TMC server");
1062
1063    if log::log_enabled!(log::Level::Trace) {
1064        // Only log the URL path, not query parameters which may contain credentials
1065        if let Ok(url) = oauth_request.uri().to_string().parse::<reqwest::Url>() {
1066            trace!("OAuth request path: {}", url.path());
1067        }
1068    }
1069
1070    let parsed_key = get_ratelimit_api_key()?;
1071
1072    debug!("Building request to TMC server");
1073    let request = REQWEST_CLIENT
1074        .request(
1075            oauth_request.method().clone(),
1076            oauth_request
1077                .uri()
1078                .to_string()
1079                .parse::<reqwest::Url>()
1080                .map_err(|e| HttpClientError::Other(format!("Invalid URL: {}", e)))?,
1081        )
1082        .headers(oauth_request.headers().clone())
1083        .version(oauth_request.version())
1084        .header("RATELIMIT-PROTECTION-SAFE-API-KEY", parsed_key)
1085        .body(oauth_request.body().to_vec());
1086
1087    debug!("Sending request to TMC server");
1088    let response = request
1089        .send()
1090        .await
1091        .map_err(|e| HttpClientError::Other(format!("Failed to execute request: {}", e)))?;
1092
1093    // Log response status and version, but not headers or body which may contain tokens
1094    debug!(
1095        "Received response from TMC server - Status: {}, Version: {:?}",
1096        response.status(),
1097        response.version()
1098    );
1099
1100    let status = response.status();
1101    let version = response.version();
1102    let headers = response.headers().clone();
1103
1104    debug!("Reading response body");
1105    let body_bytes = response
1106        .bytes()
1107        .await
1108        .map_err(|e| HttpClientError::Other(format!("Failed to read response body: {}", e)))?
1109        .to_vec();
1110
1111    debug!("Building OAuth response");
1112    let mut builder = oauth2::http::Response::builder()
1113        .status(status)
1114        .version(version);
1115
1116    if let Some(builder_headers) = builder.headers_mut() {
1117        builder_headers.extend(headers.iter().map(|(k, v)| (k.clone(), v.clone())));
1118    }
1119
1120    let oauth_response = builder
1121        .body(body_bytes)
1122        .map_err(|e| HttpClientError::Other(format!("Failed to construct response: {}", e)))?;
1123
1124    debug!("Successfully completed OAuth request");
1125    Ok(oauth_response)
1126}
1127
1128#[cfg(test)]
1129mod test {
1130    use super::*;
1131    use crate::test_helper::*;
1132    use headless_lms_models::*;
1133    use models::roles::RoleDomain;
1134
1135    #[actix_web::test]
1136    async fn test_authorization() {
1137        let mut conn = Conn::init().await;
1138        let mut tx = conn.begin().await;
1139
1140        let user = users::insert(
1141            tx.as_mut(),
1142            PKeyPolicy::Generate,
1143            "auth@example.com",
1144            None,
1145            None,
1146        )
1147        .await
1148        .unwrap();
1149        let org = organizations::insert(tx.as_mut(), PKeyPolicy::Generate, "auth", "auth", "auth")
1150            .await
1151            .unwrap();
1152
1153        authorize(
1154            tx.as_mut(),
1155            Action::Edit,
1156            Some(user),
1157            Resource::Organization(org),
1158        )
1159        .await
1160        .unwrap_err();
1161
1162        roles::insert(
1163            tx.as_mut(),
1164            user,
1165            UserRole::Teacher,
1166            RoleDomain::Organization(org),
1167        )
1168        .await
1169        .unwrap();
1170
1171        authorize(
1172            tx.as_mut(),
1173            Action::Edit,
1174            Some(user),
1175            Resource::Organization(org),
1176        )
1177        .await
1178        .unwrap();
1179    }
1180
1181    #[actix_web::test]
1182    async fn course_role_chapter_resource() {
1183        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter);
1184
1185        authorize(
1186            tx.as_mut(),
1187            Action::Edit,
1188            Some(user),
1189            Resource::Chapter(chapter),
1190        )
1191        .await
1192        .unwrap_err();
1193
1194        roles::insert(
1195            tx.as_mut(),
1196            user,
1197            UserRole::Teacher,
1198            RoleDomain::Course(course),
1199        )
1200        .await
1201        .unwrap();
1202
1203        authorize(
1204            tx.as_mut(),
1205            Action::Edit,
1206            Some(user),
1207            Resource::Chapter(chapter),
1208        )
1209        .await
1210        .unwrap();
1211    }
1212
1213    #[actix_web::test]
1214    async fn anonymous_user_can_view_open_course() {
1215        insert_data!(:tx, :user, :org, :course);
1216
1217        authorize(tx.as_mut(), Action::View, None, Resource::Course(course))
1218            .await
1219            .unwrap();
1220    }
1221}