headless_lms_server/controllers/
auth.rs

1/*!
2Handlers for HTTP requests to `/api/v0/auth`.
3*/
4
5use crate::{
6    OAuthClient,
7    domain::{
8        authorization::{
9            self, ActionOnResource, authorize_with_fetched_list_of_roles, skip_authorize,
10        },
11        rate_limit_middleware_builder::build_rate_limiting_middleware,
12    },
13    prelude::*,
14};
15use actix_session::Session;
16use anyhow::Error;
17use anyhow::anyhow;
18use headless_lms_models::ModelResult;
19use headless_lms_utils::tmc::{NewUserInfo, TmcClient};
20use std::time::Duration;
21use tracing_log::log;
22
23#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
24#[cfg_attr(feature = "ts_rs", derive(TS))]
25pub struct Login {
26    email: String,
27    password: String,
28}
29
30/**
31POST `/api/v0/auth/authorize` checks whether user can perform specified action on specified resource.
32**/
33
34#[instrument(skip(pool, payload,))]
35pub async fn authorize_action_on_resource(
36    pool: web::Data<PgPool>,
37    user: Option<AuthUser>,
38    payload: web::Json<ActionOnResource>,
39) -> ControllerResult<web::Json<bool>> {
40    let mut conn = pool.acquire().await?;
41    let data = payload.0;
42    if let Some(user) = user {
43        match authorize(&mut conn, data.action, Some(user.id), data.resource).await {
44            Ok(true_token) => true_token.authorized_ok(web::Json(true)),
45            _ => {
46                // We went to return success message even if the authorization fails.
47                let false_token = skip_authorize();
48                false_token.authorized_ok(web::Json(false))
49            }
50        }
51    } else {
52        // Never authorize anonymous user
53        let false_token = skip_authorize();
54        false_token.authorized_ok(web::Json(false))
55    }
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59#[cfg_attr(feature = "ts_rs", derive(TS))]
60pub struct CreateAccountDetails {
61    pub email: String,
62    pub first_name: String,
63    pub last_name: String,
64    pub language: String,
65    pub password: String,
66    pub password_confirmation: String,
67    pub country: String,
68    pub email_communication_consent: bool,
69}
70
71/**
72POST `/api/v0/auth/signup` Creates new mooc.fi account and signs in.
73
74# Example
75```http
76POST /api/v0/auth/signup HTTP/1.1
77Content-Type: application/json
78
79{
80  "email": "student@example.com",
81  "first_name": "John",
82  "last_name": "Doe",
83  "language": "en",
84  "password": "hunter42",
85  "password_confirmation": "hunter42",
86  "country" : "Finland",
87  "email_communication_consent": true
88}
89```
90*/
91#[instrument(skip(session, pool, client, payload, app_conf))]
92pub async fn signup(
93    session: Session,
94    payload: web::Json<CreateAccountDetails>,
95    pool: web::Data<PgPool>,
96    client: web::Data<OAuthClient>,
97    user: Option<AuthUser>,
98    app_conf: web::Data<ApplicationConfiguration>,
99    tmc_client: web::Data<TmcClient>,
100) -> ControllerResult<HttpResponse> {
101    let user_details = payload.0;
102    let mut conn = pool.acquire().await?;
103
104    if app_conf.test_mode {
105        return handle_test_mode_signup(&mut conn, &session, &user_details, &app_conf).await;
106    }
107
108    if user.is_none() {
109        // First create the actual user to tmc.mooc.fi and then fetch it from mooc.fi
110        post_new_user_to_moocfi(&user_details, tmc_client, &app_conf).await?;
111
112        let auth_result = authorization::authenticate_moocfi_user(
113            &mut conn,
114            &client,
115            user_details.email,
116            user_details.password,
117        )
118        .await?;
119
120        if let Some((user, _token)) = auth_result {
121            let country = user_details.country.clone();
122            models::user_details::update_user_country(&mut conn, user.id, &country).await?;
123            models::user_details::update_user_email_communication_consent(
124                &mut conn,
125                user.id,
126                user_details.email_communication_consent,
127            )
128            .await?;
129
130            let token = skip_authorize();
131            authorization::remember(&session, user)?;
132            token.authorized_ok(HttpResponse::Ok().finish())
133        } else {
134            Err(ControllerError::new(
135                ControllerErrorType::Unauthorized,
136                "Incorrect email or password.".to_string(),
137                None,
138            ))
139        }
140    } else {
141        Err(ControllerError::new(
142            ControllerErrorType::BadRequest,
143            "Cannot create a new account when signed in.".to_string(),
144            None,
145        ))
146    }
147}
148
149async fn handle_test_mode_signup(
150    conn: &mut PgConnection,
151    session: &Session,
152    user_details: &CreateAccountDetails,
153    app_conf: &ApplicationConfiguration,
154) -> ControllerResult<HttpResponse> {
155    assert!(
156        app_conf.test_mode,
157        "handle_test_mode_signup called outside test mode"
158    );
159
160    warn!("Handling signup in test mode. No real account is created.");
161
162    let user_id = models::users::insert(
163        conn,
164        PKeyPolicy::Generate,
165        &user_details.email,
166        Some(&user_details.first_name),
167        Some(&user_details.last_name),
168    )
169    .await
170    .map_err(|e| {
171        ControllerError::new(
172            ControllerErrorType::InternalServerError,
173            "Failed to insert test user.".to_string(),
174            Some(anyhow!(e)),
175        )
176    })?;
177
178    models::user_details::update_user_country(conn, user_id, &user_details.country).await?;
179    models::user_details::update_user_email_communication_consent(
180        conn,
181        user_id,
182        user_details.email_communication_consent,
183    )
184    .await?;
185
186    let user = models::users::get_by_email(conn, &user_details.email).await?;
187    authorization::remember(session, user)?;
188
189    let token = skip_authorize();
190    token.authorized_ok(HttpResponse::Ok().finish())
191}
192
193/**
194POST `/api/v0/auth/authorize-multiple` checks whether user can perform specified action on specified resource.
195Returns booleans for the authorizations in the same order as the input.
196**/
197
198#[instrument(skip(pool, payload,))]
199pub async fn authorize_multiple_actions_on_resources(
200    pool: web::Data<PgPool>,
201    user: Option<AuthUser>,
202    payload: web::Json<Vec<ActionOnResource>>,
203) -> ControllerResult<web::Json<Vec<bool>>> {
204    let mut conn = pool.acquire().await?;
205    let input = payload.into_inner();
206    let mut results = Vec::with_capacity(input.len());
207    if let Some(user) = user {
208        // Prefetch roles so that we can do multiple authorizations without repeteadly querying the database.
209        let user_roles = models::roles::get_roles(&mut conn, user.id).await?;
210
211        for action_on_resource in input {
212            if (authorize_with_fetched_list_of_roles(
213                &mut conn,
214                action_on_resource.action,
215                Some(user.id),
216                action_on_resource.resource,
217                &user_roles,
218            )
219            .await)
220                .is_ok()
221            {
222                results.push(true);
223            } else {
224                results.push(false);
225            }
226        }
227    } else {
228        // Never authorize anonymous user
229        for _action_on_resource in input {
230            results.push(false);
231        }
232    }
233    let token = skip_authorize();
234    token.authorized_ok(web::Json(results))
235}
236
237/**
238POST `/api/v0/auth/login` Logs in to TMC.
239Returns true if login was successful, false if credentials were incorrect.
240**/
241#[instrument(skip(session, pool, client, payload, app_conf))]
242pub async fn login(
243    session: Session,
244    pool: web::Data<PgPool>,
245    client: web::Data<OAuthClient>,
246    app_conf: web::Data<ApplicationConfiguration>,
247    payload: web::Json<Login>,
248) -> ControllerResult<web::Json<bool>> {
249    let mut conn = pool.acquire().await?;
250    let Login { email, password } = payload.into_inner();
251
252    if app_conf.development_uuid_login {
253        warn!("Trying development mode UUID login");
254        if let Ok(id) = Uuid::parse_str(&email) {
255            let user = { models::users::get_by_id(&mut conn, id).await? };
256            let token = skip_authorize();
257            authorization::remember(&session, user)?;
258            return token.authorized_ok(web::Json(true));
259        };
260    }
261
262    let success = if app_conf.test_mode {
263        warn!("Using test credentials. Normal accounts won't work.");
264        let success =
265            authorization::authenticate_test_user(&mut conn, &email, &password, &app_conf)
266                .await
267                .map_err(|e| {
268                    ControllerError::new(
269                        ControllerErrorType::Unauthorized,
270                        "Could not find the test user. Have you seeded the database?".to_string(),
271                        e,
272                    )
273                })?;
274        if success {
275            let user = models::users::get_by_email(&mut conn, &email).await?;
276            authorization::remember(&session, user)?;
277        }
278        success
279    } else {
280        let auth_result =
281            authorization::authenticate_moocfi_user(&mut conn, &client, email, password).await?;
282
283        if let Some((user, _token)) = auth_result {
284            authorization::remember(&session, user)?;
285            true
286        } else {
287            false
288        }
289    };
290
291    if success {
292        info!("Authentication successful");
293    } else {
294        warn!("Authentication failed");
295    }
296
297    let token = skip_authorize();
298    token.authorized_ok(web::Json(success))
299}
300
301/**
302POST `/api/v0/auth/logout` Logs out.
303**/
304#[instrument(skip(session))]
305#[allow(clippy::async_yields_async)]
306pub async fn logout(session: Session) -> HttpResponse {
307    authorization::forget(&session);
308    HttpResponse::Ok().finish()
309}
310
311/**
312GET `/api/v0/auth/logged-in` Returns the current user's login status.
313**/
314#[instrument(skip(session))]
315pub async fn logged_in(session: Session, pool: web::Data<PgPool>) -> web::Json<bool> {
316    let logged_in = authorization::has_auth_user_session(&session, pool).await;
317    web::Json(logged_in)
318}
319
320/// Generic information about the logged in user.
321///
322///  Could include the user name etc in the future.
323#[derive(Debug, Serialize)]
324#[cfg_attr(feature = "ts_rs", derive(TS))]
325pub struct UserInfo {
326    pub user_id: Uuid,
327    pub first_name: Option<String>,
328    pub last_name: Option<String>,
329}
330
331/**
332GET `/api/v0/auth/user-info` Returns the current user's info.
333**/
334
335#[instrument(skip(auth_user, pool))]
336pub async fn user_info(
337    auth_user: Option<AuthUser>,
338    pool: web::Data<PgPool>,
339) -> ControllerResult<web::Json<Option<UserInfo>>> {
340    let token = skip_authorize();
341    if let Some(auth_user) = auth_user {
342        let mut conn = pool.acquire().await?;
343        let user_details =
344            models::user_details::get_user_details_by_user_id(&mut conn, auth_user.id).await?;
345
346        token.authorized_ok(web::Json(Some(UserInfo {
347            user_id: user_details.user_id,
348            first_name: user_details.first_name,
349            last_name: user_details.last_name,
350        })))
351    } else {
352        token.authorized_ok(web::Json(None))
353    }
354}
355
356/// Posts new user account to tmc.mooc.fi.
357///
358/// Based on implementation from <https://github.com/rage/mooc.fi/blob/fb9a204f4dbf296b35ec82b2442e1e6ae0641fe9/frontend/lib/account.ts>
359pub async fn post_new_user_to_moocfi(
360    user_details: &CreateAccountDetails,
361    tmc_client: web::Data<TmcClient>,
362    app_conf: &ApplicationConfiguration,
363) -> anyhow::Result<()> {
364    tmc_client
365        .post_new_user_to_moocfi(
366            NewUserInfo {
367                first_name: user_details.first_name.clone(),
368                last_name: user_details.last_name.clone(),
369                email: user_details.email.clone(),
370                password: user_details.password.clone(),
371                password_confirmation: user_details.password_confirmation.clone(),
372                language: user_details.language.clone(),
373            },
374            app_conf,
375        )
376        .await
377}
378
379pub async fn update_user_information_to_tmc(
380    first_name: String,
381    last_name: String,
382    email: Option<String>,
383    user_upstream_id: String,
384    tmc_client: web::Data<TmcClient>,
385    app_conf: web::Data<ApplicationConfiguration>,
386) -> Result<(), Error> {
387    if app_conf.test_mode {
388        return Ok(());
389    }
390    tmc_client
391        .update_user_information(first_name, last_name, email, user_upstream_id)
392        .await
393        .map_err(|e| {
394            log::warn!("TMC user update failed: {:?}", e);
395            anyhow::anyhow!("TMC user update failed: {}", e)
396        })?;
397    Ok(())
398}
399
400pub async fn is_user_global_admin(conn: &mut PgConnection, user_id: Uuid) -> ModelResult<bool> {
401    let roles = models::roles::get_roles(conn, user_id).await?;
402    Ok(roles
403        .iter()
404        .any(|r| r.role == models::roles::UserRole::Admin && r.is_global))
405}
406
407pub fn _add_routes(cfg: &mut ServiceConfig) {
408    cfg.service(
409        web::resource("/signup")
410            .wrap(build_rate_limiting_middleware(Duration::from_secs(60), 15))
411            .wrap(build_rate_limiting_middleware(
412                Duration::from_secs(60 * 60 * 24),
413                1000,
414            ))
415            .to(signup),
416    )
417    .service(
418        web::resource("/login")
419            .wrap(build_rate_limiting_middleware(Duration::from_secs(60), 20))
420            .wrap(build_rate_limiting_middleware(
421                Duration::from_secs(60 * 60),
422                100,
423            ))
424            .wrap(build_rate_limiting_middleware(
425                Duration::from_secs(60 * 60 * 24),
426                500,
427            ))
428            .to(login),
429    )
430    .route("/logout", web::post().to(logout))
431    .route("/logged-in", web::get().to(logged_in))
432    .route("/authorize", web::post().to(authorize_action_on_resource))
433    .route(
434        "/authorize-multiple",
435        web::post().to(authorize_multiple_actions_on_resources),
436    )
437    .route("/user-info", web::get().to(user_info));
438}