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