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