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