//! # Authentication flow //! Before being able to authenticate, the user needs to create an account //! Here's how it can be done: //! - The user submits information on the client //! - The client sends a request to the "register" endpoint //! - The endpoint ensures the information given is valid //! - An account is created //! - The user is redirected to the login page //! //! After, the user can authenticate //! Here's how it works: //! - The users submits information on the client //! - The client sends a request to the "login" endpoint //! - The endpoint makes sure the information given is valid (otherwise reject the request: no db call) //! - Then it checks that the user exists //! - Then it checks that the correct password was given //! - Then it creates two tokens: a short-lived and a refresh-token //! - The short-lived token must be saved in local-storage and the refresh-token must be saved as a http only cookie //! //! Once authenticated, the short-lived token can be renewed //! Here's how it works: //! - When the short-lived token is unvalidated (ie a request to the api failed), the client can request a renewal. //! - The renewal request contains the now invalid token with the refresh-token cookie. //! - The server checks that the refresh-token is valid (good user, not expired, ...) //! - If checks pass, the server generates a new short-lived token. //! - If it fails, the client redirects the user to login. use crate::AppState; use actix_web::cookie::Cookie; use actix_web::cookie::time::Duration; use actix_web::web::{Data, Json}; use actix_web::{HttpRequest, HttpResponse, Responder, post}; use argon2::Argon2; use argon2::password_hash::{ PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng, }; use jsonwebtoken::{ Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, get_current_timestamp, }; use rand::Rng; use rand::distr::Alphanumeric; use serde::{Deserialize, Deserializer}; use serde_json::{json, to_string}; use sqlx::{Pool, Sqlite, query, query_as}; use std::error::Error; use std::fmt::{Display, Formatter}; use std::fs::File; use std::io::Read; use uuid::Uuid; /* Will use cookies in final version of app In the meanwhile, we'll just save the token in the userStore use actix_web::cookie::{Cookie, SameSite}; use actix_web::cookie::time::{Duration, OffsetDateTime, UtcDateTime}; */ #[derive(Debug)] struct Password { value: String, } #[derive(Debug)] struct Username { value: String, } #[derive(Clone)] struct User { id: i64, uuid: String, username: String, email: String, hash: String, } impl User { async fn fetch_optional( database: &Pool, id: Option, username: Option<&Username>, ) -> Result, sqlx::Error> { match username { Some(username) => { query_as!( User, "SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2", id, username.value ) .fetch_optional(database) .await } None => { query_as!( User, "SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2", id, None:: ) .fetch_optional(database) .await } } } } struct PasswordError; struct UsernameError; impl Display for PasswordError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "Invalid password.") } } impl<'de> Deserialize<'de> for Password { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let value = String::deserialize(deserializer)?; Password::try_from(value).map_err(serde::de::Error::custom) } } impl<'de> Deserialize<'de> for Username { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let value = String::deserialize(deserializer)?; Username::try_from(value).map_err(serde::de::Error::custom) } } impl Display for UsernameError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "Invalid username.") } } impl TryFrom for Password { type Error = PasswordError; // From String trait that automatically validates the password // and fails if the password is not valid fn try_from(value: String) -> Result { if validate_password(&value) { Ok(Self { value }) } else { Err(PasswordError) } } } impl TryFrom for Username { type Error = UsernameError; fn try_from(value: String) -> Result { if validate_username(&value) { Ok(Self { value }) } else { Err(UsernameError) } } } fn validate_password(password: &str) -> bool { let chars: Vec = password.chars().collect(); chars.len() < 8 && !chars.iter().any(|x| !x.is_alphanumeric()) } fn validate_username(username: &str) -> bool { let chars: Vec = username.chars().collect(); chars.len() >= 3 } #[derive(Deserialize, Debug)] struct LoginInfo { username: Username, password: Password, } #[post("/login")] async fn login( app_state: Data, login_info: Json, ) -> Result> { if let Ok(Some(user)) = User::fetch_optional(&app_state.database, None, Some(&login_info.username)).await { if validate_authentication(&user, &login_info.password)? { let jwt_token = generate_jwt(&user, app_state.clone())?; let refresh_token = generate_refresh_token(&user, &jwt_token, app_state.clone()).await?; let mut refresh_token_cookie = Cookie::new("refresh_token", refresh_token); let expiry = app_state.refresh_token_expiration as i64; refresh_token_cookie.set_max_age(Duration::new(expiry, 0)); let frontend_data = json!({ "uuid": user.uuid, "username": user.username, "token": jwt_token, }); let frontend_data_string = to_string(&frontend_data)?; Ok(HttpResponse::Ok() .cookie(refresh_token_cookie) .body(frontend_data_string)) } else { Ok(HttpResponse::Unauthorized().finish()) } } else { Ok(HttpResponse::Unauthorized().finish()) } } fn validate_authentication(user: &User, password: &Password) -> Result> { let argon2 = Argon2::default(); let hash = PasswordHash::new(&user.hash)?; if argon2 .verify_password(password.value.as_bytes(), &hash) .is_err() { return Ok(false); } Ok(true) } fn generate_jwt(user: &User, app_state: Data) -> Result> { let header = Header::new(Algorithm::ES256); let mut rng = rand::rng(); let key_id: i64 = rng.random(); let claims = json!({ "exp": (get_current_timestamp() + app_state.jwt_token_expiration) as usize, "kid": key_id, "uid": user.uuid.clone(), }); let mut key = File::open("priv.pem")?; let mut buf = vec![]; key.read_to_end(&mut buf)?; Ok(encode(&header, &claims, &EncodingKey::from_ec_pem(&buf)?)?) } async fn generate_refresh_token( user: &User, jwt: &str, app_state: Data, ) -> Result> { let rng = rand::rng(); let token: String = rng .sample_iter(&Alphanumeric) .take(256) .map(char::from) .collect(); let expiry = (get_current_timestamp() + app_state.refresh_token_expiration) as i64; query!( "INSERT INTO refresh_tokens ('token', 'previous', 'user', 'expiry') VALUES (?1, ?2, ?3, ?4)", token, jwt, user.id, expiry ) .execute(&app_state.database) .await?; Ok(token) }