Skip to main content

headless_lms_server/domain/
authorization.rs

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