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.expose_secret(),
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: SecretString,
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: SecretString,
806) -> anyhow::Result<Option<SecretString>> {
807    let token_result = client
808        .exchange_password(
809            &ResourceOwnerUsername::new(email),
810            // Exposed only here, at the OAuth2 client boundary.
811            &ResourceOwnerPassword::new(password.expose_secret().to_string()),
812        )
813        .request_async(&async_http_client_with_headers)
814        .await;
815    match token_result {
816        Ok(token) => Ok(Some(SecretString::new(
817            token.access_token().secret().to_owned().into(),
818        ))),
819        Err(RequestTokenError::ServerResponse(server_response)) => {
820            let error = server_response.error();
821            let error_description = server_response.error_description();
822            let error_uri = server_response.error_uri();
823
824            // Only return Ok(None) for InvalidGrant errors (wrong email/password)
825            if let oauth2::basic::BasicErrorResponseType::InvalidGrant = error {
826                warn!(
827                    ?error_description,
828                    ?error_uri,
829                    "TMC did not accept the credentials: {}",
830                    error
831                );
832                Ok(None)
833            } else {
834                // For all other error types, return an error
835                error!(
836                    ?error_description,
837                    ?error_uri,
838                    "TMC authentication error: {}",
839                    error
840                );
841                Err(anyhow::anyhow!("Authentication error: {}", error))
842            }
843        }
844        Err(e) => {
845            error!("Failed to exchange password with TMC: {}", e);
846            Err(e.into())
847        }
848    }
849}
850
851/// Fetches the mooc.fi UUID for a user by their upstream ID using the TMC access token.
852async fn fetch_moocfi_id_by_upstream_id(
853    tmc_access_token: &SecretString,
854    upstream_id: i32,
855) -> anyhow::Result<Option<Uuid>> {
856    info!("Fetching mooc.fi UUID for upstream user id {}", upstream_id);
857
858    let res = REQWEST_CLIENT
859        .post(MOOCFI_GRAPHQL_URL)
860        .header(reqwest::header::CONTENT_TYPE, "application/json")
861        .header(reqwest::header::ACCEPT, "application/json")
862        // Exposed only here, where the bearer token header is built.
863        .bearer_auth(tmc_access_token.expose_secret())
864        .json(&GraphQLRequest {
865            query: r#"
866query ($upstreamId: Int) {
867  user(upstream_id: $upstreamId) {
868    id
869  }
870}"#,
871            variables: Some(json!({ "upstreamId": upstream_id })),
872        })
873        .send()
874        .await;
875
876    match res {
877        Ok(response) => {
878            if !response.status().is_success() {
879                debug!(
880                    "Failed to fetch mooc.fi user with status {}. Will generate new UUID instead.",
881                    response.status()
882                );
883                return Ok(None);
884            }
885
886            match response.json::<MoocfiUserResponse>().await {
887                Ok(current_user_response) => {
888                    info!(
889                        "Successfully fetched mooc.fi UUID {} for upstream id {}",
890                        current_user_response.data.user.id, upstream_id
891                    );
892                    Ok(Some(current_user_response.data.user.id))
893                }
894                Err(e) => {
895                    debug!(
896                        "Failed to parse mooc.fi response: {}. Will generate new UUID instead.",
897                        e
898                    );
899                    Ok(None)
900                }
901            }
902        }
903        Err(e) => {
904            debug!(
905                "Failed to fetch from mooc.fi: {}. Will generate new UUID instead.",
906                e
907            );
908            Ok(None)
909        }
910    }
911}
912
913pub async fn get_or_create_user_from_tmc_mooc_fi_response(
914    conn: &mut PgConnection,
915    tmc_mooc_fi_user: TMCUser,
916    tmc_access_token: &SecretString,
917) -> anyhow::Result<User> {
918    let TMCUser {
919        id: upstream_id,
920        email,
921        courses_mooc_fi_user_id: moocfi_id,
922        user_field,
923        ..
924    } = tmc_mooc_fi_user;
925
926    // If moocfi_id is None, try to fetch it from mooc.fi before generating a new UUID
927    let id = match moocfi_id {
928        Some(id) => id,
929        None => match fetch_moocfi_id_by_upstream_id(tmc_access_token, upstream_id).await? {
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: &SecretString,
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    // Test-only seeded credentials; exposed once here for the literal comparisons below.
981    let password = password.expose_secret();
982
983    let _user = if email == "admin@example.com" && password == "admin" {
984        models::users::get_by_email(conn, "admin@example.com").await?
985    } else if email == "teacher@example.com" && password == "teacher" {
986        models::users::get_by_email(conn, "teacher@example.com").await?
987    } else if email == "language.teacher@example.com" && password == "language.teacher" {
988        models::users::get_by_email(conn, "language.teacher@example.com").await?
989    } else if email == "material.viewer@example.com" && password == "material.viewer" {
990        models::users::get_by_email(conn, "material.viewer@example.com").await?
991    } else if email == "user@example.com" && password == "user" {
992        models::users::get_by_email(conn, "user@example.com").await?
993    } else if email == "assistant@example.com" && password == "assistant" {
994        models::users::get_by_email(conn, "assistant@example.com").await?
995    } else if email == "creator@example.com" && password == "creator" {
996        models::users::get_by_email(conn, "creator@example.com").await?
997    } else if email == "student1@example.com" && password == "student1" {
998        models::users::get_by_email(conn, "student1@example.com").await?
999    } else if email == "student2@example.com" && password == "student2" {
1000        models::users::get_by_email(conn, "student2@example.com").await?
1001    } else if email == "student3@example.com" && password == "student3" {
1002        models::users::get_by_email(conn, "student3@example.com").await?
1003    } else if email == "student4@example.com" && password == "student4" {
1004        models::users::get_by_email(conn, "student4@example.com").await?
1005    } else if email == "student5@example.com" && password == "student5" {
1006        models::users::get_by_email(conn, "student5@example.com").await?
1007    } else if email == "student6@example.com" && password == "student6" {
1008        models::users::get_by_email(conn, "student6@example.com").await?
1009    } else if email == "student7@example.com" && password == "student7" {
1010        models::users::get_by_email(conn, "student7@example.com").await?
1011    } else if email == "student8@example.com" && password == "student8" {
1012        models::users::get_by_email(conn, "student8@example.com").await?
1013    } else if email == "teaching-and-learning-services@example.com"
1014        && password == "teaching-and-learning-services"
1015    {
1016        models::users::get_by_email(conn, "teaching-and-learning-services@example.com").await?
1017    } else if email == "student-without-research-consent@example.com"
1018        && password == "student-without-research-consent"
1019    {
1020        models::users::get_by_email(conn, "student-without-research-consent@example.com").await?
1021    } else if email == "student-without-country@example.com"
1022        && password == "student-without-country"
1023    {
1024        models::users::get_by_email(conn, "student-without-country@example.com").await?
1025    } else if email == "langs@example.com" && password == "langs" {
1026        models::users::get_by_email(conn, "langs@example.com").await?
1027    } else if email == "sign-up-user@example.com" && password == "sign-up-user" {
1028        models::users::get_by_email(conn, "sign-up-user@example.com").await?
1029    } else {
1030        info!("Authentication failed: incorrect test credentials");
1031        return Ok(false);
1032    };
1033    info!("Successfully authenticated test user {}", email);
1034    Ok(true)
1035}
1036
1037// Only used for testing, not to use in production.
1038pub async fn authenticate_test_token(
1039    conn: &mut PgConnection,
1040    _token: &SecretString,
1041    application_configuration: &ApplicationConfiguration,
1042) -> anyhow::Result<User> {
1043    // 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.
1044    assert!(application_configuration.test_mode);
1045    // TODO: this has never worked
1046    let user = models::users::get_by_email(conn, "TODO").await?;
1047    Ok(user)
1048}
1049
1050/**
1051 Gets the rate limit protection API key from environment variables and converts it to a header value.
1052 This key is used to bypass rate limiting when making requests to TMC server.
1053*/
1054fn get_ratelimit_api_key() -> Result<reqwest::header::HeaderValue, HttpClientError<reqwest::Error>>
1055{
1056    let key = server_runtime_config()
1057        .ratelimit_protection_safe_api_key
1058        .clone();
1059    debug!("Using ratelimit API key from runtime config");
1060
1061    key.expose_secret()
1062        .parse::<reqwest::header::HeaderValue>()
1063        .map_err(|err| {
1064            error!("Invalid RATELIMIT API key format: {}", err);
1065            HttpClientError::Other("Invalid RATELIMIT API key.".to_string())
1066        })
1067}
1068
1069/**
1070 HTTP Client used only for authenticating with TMC server. This function:
1071 1. Ensures TMC server does not rate limit auth requests from backend by adding a special header
1072 2. Converts between oauth2 crate's internal http types and our reqwest types:
1073    - Converts oauth2::HttpRequest to a reqwest::Request
1074    - Makes the request using our REQWEST_CLIENT
1075    - Converts the reqwest::Response back to oauth2::HttpResponse
1076*/
1077async fn async_http_client_with_headers(
1078    oauth_request: oauth2::HttpRequest,
1079) -> Result<oauth2::HttpResponse, HttpClientError<reqwest::Error>> {
1080    debug!("Making OAuth request to TMC server");
1081
1082    if log::log_enabled!(log::Level::Trace) {
1083        // Only log the URL path, not query parameters which may contain credentials
1084        if let Ok(url) = oauth_request.uri().to_string().parse::<reqwest::Url>() {
1085            trace!("OAuth request path: {}", url.path());
1086        }
1087    }
1088
1089    let parsed_key = get_ratelimit_api_key()?;
1090
1091    debug!("Building request to TMC server");
1092    let request = REQWEST_CLIENT
1093        .request(
1094            oauth_request.method().clone(),
1095            oauth_request
1096                .uri()
1097                .to_string()
1098                .parse::<reqwest::Url>()
1099                .map_err(|e| HttpClientError::Other(format!("Invalid URL: {}", e)))?,
1100        )
1101        .headers(oauth_request.headers().clone())
1102        .version(oauth_request.version())
1103        .header("RATELIMIT-PROTECTION-SAFE-API-KEY", parsed_key)
1104        .body(oauth_request.body().to_vec());
1105
1106    debug!("Sending request to TMC server");
1107    let response = request
1108        .send()
1109        .await
1110        .map_err(|e| HttpClientError::Other(format!("Failed to execute request: {}", e)))?;
1111
1112    // Log response status and version, but not headers or body which may contain tokens
1113    debug!(
1114        "Received response from TMC server - Status: {}, Version: {:?}",
1115        response.status(),
1116        response.version()
1117    );
1118
1119    let status = response.status();
1120    let version = response.version();
1121    let headers = response.headers().clone();
1122
1123    debug!("Reading response body");
1124    let body_bytes = response
1125        .bytes()
1126        .await
1127        .map_err(|e| HttpClientError::Other(format!("Failed to read response body: {}", e)))?
1128        .to_vec();
1129
1130    debug!("Building OAuth response");
1131    let mut builder = oauth2::http::Response::builder()
1132        .status(status)
1133        .version(version);
1134
1135    if let Some(builder_headers) = builder.headers_mut() {
1136        builder_headers.extend(headers.iter().map(|(k, v)| (k.clone(), v.clone())));
1137    }
1138
1139    let oauth_response = builder
1140        .body(body_bytes)
1141        .map_err(|e| HttpClientError::Other(format!("Failed to construct response: {}", e)))?;
1142
1143    debug!("Successfully completed OAuth request");
1144    Ok(oauth_response)
1145}
1146
1147#[cfg(test)]
1148mod test {
1149    use super::*;
1150    use crate::test_helper::*;
1151    use headless_lms_models::*;
1152    use models::roles::RoleDomain;
1153
1154    #[actix_web::test]
1155    async fn test_authorization() {
1156        let mut conn = Conn::init().await;
1157        let mut tx = conn.begin().await;
1158
1159        let user = users::insert(
1160            tx.as_mut(),
1161            PKeyPolicy::Generate,
1162            "auth@example.com",
1163            None,
1164            None,
1165        )
1166        .await
1167        .unwrap();
1168        let org = organizations::insert(
1169            tx.as_mut(),
1170            PKeyPolicy::Generate,
1171            "auth",
1172            "auth",
1173            Some("auth"),
1174            false,
1175        )
1176        .await
1177        .unwrap();
1178
1179        authorize(
1180            tx.as_mut(),
1181            Action::Edit,
1182            Some(user),
1183            Resource::Organization(org),
1184        )
1185        .await
1186        .unwrap_err();
1187
1188        roles::insert(
1189            tx.as_mut(),
1190            user,
1191            UserRole::Teacher,
1192            RoleDomain::Organization(org),
1193        )
1194        .await
1195        .unwrap();
1196
1197        authorize(
1198            tx.as_mut(),
1199            Action::Edit,
1200            Some(user),
1201            Resource::Organization(org),
1202        )
1203        .await
1204        .unwrap();
1205    }
1206
1207    #[actix_web::test]
1208    async fn course_role_chapter_resource() {
1209        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter);
1210
1211        authorize(
1212            tx.as_mut(),
1213            Action::Edit,
1214            Some(user),
1215            Resource::Chapter(chapter),
1216        )
1217        .await
1218        .unwrap_err();
1219
1220        roles::insert(
1221            tx.as_mut(),
1222            user,
1223            UserRole::Teacher,
1224            RoleDomain::Course(course),
1225        )
1226        .await
1227        .unwrap();
1228
1229        authorize(
1230            tx.as_mut(),
1231            Action::Edit,
1232            Some(user),
1233            Resource::Chapter(chapter),
1234        )
1235        .await
1236        .unwrap();
1237    }
1238
1239    #[actix_web::test]
1240    async fn anonymous_user_can_view_open_course() {
1241        insert_data!(:tx, :user, :org, :course);
1242
1243        authorize(tx.as_mut(), Action::View, None, Resource::Course(course))
1244            .await
1245            .unwrap();
1246    }
1247}