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