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