//! # 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::{App, HttpRequest, HttpResponse, Responder, post}; use argon2::{Argon2, PasswordHasher}; use argon2::password_hash::{PasswordHash, PasswordVerifier, SaltString}; use argon2::password_hash::rand_core::OsRng; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp}; use rand::Rng; use rand::distr::Alphanumeric; use serde::{Deserialize, Deserializer}; use serde_json::{json, to_string}; use sqlx::sqlite::SqliteQueryResult; use sqlx::{Pool, Sqlite, query, query_as}; use std::error::Error; use std::fmt::{Display, Formatter}; use std::fs::File; use std::io::Read; #[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, uuid: Option<&str>, username: Option<&Username>, ) -> Result, sqlx::Error> { match username { Some(username) => { query_as!( User, "SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2 or 'uuid' = ?3", id, uuid, username.value ) .fetch_optional(database) .await } None => { query_as!( User, "SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2 OR 'uuid' = ?3", id, uuid, None:: ) .fetch_optional(database) .await } } } async fn create(database: &Pool, hash: PasswordHash<'_>, username: &Username, email: &String) -> Result { let mut uuid = uuid::Uuid::new_v4().to_string(); while let Ok(Some(_)) = query!("SELECT id FROM users WHERE 'uuid' = ?1", uuid).fetch_optional(database).await { uuid = uuid::Uuid::new_v4().to_string(); } let hash_string = hash.to_string(); query_as!(User, "INSERT INTO users ('uuid', 'username', 'email', 'hash') VALUES (?1, ?2, ?3, ?4) RETURNING *", uuid, username.value, email, hash_string ) .fetch_one(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, 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()) } } #[derive(Deserialize, Debug)] struct LogoutInfo { uuid: String, token: String, } #[post("/logout")] async fn logout( app_state: Data, logout_info: Json, req: HttpRequest, ) -> Result> { let refresh_cookie = match req.cookie("refresh_token") { Some(cookie) => cookie, None => return Ok(HttpResponse::Unauthorized().finish()), }; let refresh_token = refresh_cookie.value(); match User::fetch_optional(&app_state.database, None, Some(&logout_info.uuid), None).await? { Some(user) => { let now = get_current_timestamp() as i64; let result: SqliteQueryResult = query!( "UPDATE refresh_tokens SET expiry = ?1 WHERE token = ?2 AND user = ?3 AND previous = ?4", now, refresh_token, user.id, logout_info.token ) .execute(&app_state.database) .await?; if result.rows_affected() == 0 { return Ok(HttpResponse::Unauthorized().finish()); } let mut refresh_token_cookie = Cookie::new("refresh_token", ""); refresh_token_cookie.make_removal(); Ok(HttpResponse::Ok().cookie(refresh_token_cookie).finish()) } None => Ok(HttpResponse::Unauthorized().finish()), } } #[derive(Deserialize, Debug)] struct RegisterInfo { username: Username, password: Password, email: String, } #[post("/register")] async fn register( app_state: Data, register_info: Json, ) -> Result> { if let Ok(Some(_)) = User::fetch_optional(&app_state.database, None, None, Some(®ister_info.username)).await { return Ok(HttpResponse::InternalServerError().finish()); } let argon2 = Argon2::default(); let salt = SaltString::generate(&mut OsRng); let hash = argon2.hash_password(register_info.password.value.as_bytes(), &salt)?; User::create(&app_state.database, hash, ®ister_info.username, ®ister_info.email).await?; Ok(HttpResponse::Ok().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() { Ok(false) } else { 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) }