diff --git a/src/routes/auth.rs b/src/routes/auth.rs index ab912b33..56562de8 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,10 +1,7 @@ use std::sync::Arc; use crate::{ - config::RuntimeConfig, - database::entities::{Player, PlayerRole}, - services::sessions::Sessions, - utils::hashing::{hash_password, verify_password}, + config::RuntimeConfig, database::entities::{Player, PlayerRole}, services::sessions::Sessions, session::{models::messaging::MessageNotify, packet::Packet}, utils::{components::messaging, hashing::{hash_password, verify_password}} }; use axum::{ http::StatusCode, @@ -30,6 +27,10 @@ pub enum AuthError { #[error("Provided credentials are not valid")] InvalidCredentials, + /// Provided account didn't exist + #[error("No matching account")] + NoMatchingAccount, + /// Provided username was not valid #[error("Provided username is invalid")] InvalidUsername, @@ -45,6 +46,18 @@ pub enum AuthError { /// Server has disabled account creation on dashboard #[error("This server has disabled dashboard account registration")] RegistrationDisabled, + + /// Session is not active + #[error("This player is not currently connected, please connect to the server and visit the main menu in-game before attempting this action.")] + SessionNotActive, + + /// Failed to create login code + #[error("Failed to generate login code")] + FailedGenerateCode, + + /// Failed to create login code + #[error("The provided login code was incorrect")] + InvalidCode, } /// Response type alias for JSON responses with AuthError @@ -149,13 +162,104 @@ pub async fn create( Ok(Json(TokenResponse { token })) } + +/// Request structure for requesting a login code +#[derive(Deserialize)] +pub struct RequestLoginCodeRequest { + /// The email address of the account to login with + email: String, +} + +/// POST /api/auth/request-code +/// +/// Requests a login code be sent to a active session to be used +/// for logging in without a password +pub async fn handle_request_login_code( + Extension(db): Extension, + Extension(sessions): Extension>, + Json(RequestLoginCodeRequest { email }): Json, +) -> Result { + // Player must exist + let player = Player::by_email(&db, &email) + .await? + .ok_or(AuthError::NoMatchingAccount)?; + + // Session must be active + let session = sessions + .lookup_session(player.id) + .ok_or(AuthError::SessionNotActive)?; + + // Generate the login code + let login_code = sessions.create_login_code(player.id).map_err(|_|AuthError::FailedGenerateCode)?; + + // Create and serialize the message + let origin_message = serde_json::to_string(&SystemMessage { + title : "Login Confirmation Code".to_string(), + message: format!("Your login confirmation code is {login_code}, enter this on the dashboard to login"), + image: "".to_string(), + ty:0, + tracking_id: -1, + priority: 1 + }).map_err(|_|AuthError::FailedGenerateCode)?; + + let notify_origin = Packet::notify( + messaging::COMPONENT, + messaging::SEND_MESSAGE, + MessageNotify { + message: format!("[SYSTEM_TERMINAL]{origin_message}"), + player_id: player.id, + }, + ); + + // Send the message + session.notify_handle().notify(notify_origin); + + + Ok(StatusCode::OK) +} + +#[derive(Deserialize, Serialize)] +pub struct SystemMessage { + title: String, + message: String, + image: String, + ty: u8, + tracking_id: i32, + priority: i32, +} + +/// Request structure for requesting a login code +#[derive(Deserialize)] +pub struct RequestExchangeLoginCode { + /// The email address of the account to login with + login_code: String, +} + +/// POST /api/auth/exchange-code +/// +/// Requests a login code be sent to a active session to be used +/// for logging in without a password +pub async fn handle_exchange_login_code( + Extension(sessions): Extension>, + Json(RequestExchangeLoginCode { login_code }): Json, +) -> AuthRes { + + // Exchange the code for a token + let token = sessions.exchange_login_code(&login_code).ok_or(AuthError::InvalidCode)?; + + Ok(Json(TokenResponse { token })) +} + + /// Response implementation for auth errors impl IntoResponse for AuthError { fn into_response(self) -> Response { let status_code = match &self { - Self::Database(_) | Self::PasswordHash(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Database(_) | Self::PasswordHash(_) | Self::FailedGenerateCode => StatusCode::INTERNAL_SERVER_ERROR, Self::InvalidCredentials | Self::OriginAccess => StatusCode::UNAUTHORIZED, - Self::EmailTaken | Self::InvalidUsername => StatusCode::BAD_REQUEST, + Self::EmailTaken | Self::InvalidUsername | Self::SessionNotActive | Self::NoMatchingAccount | Self::InvalidCode => { + StatusCode::BAD_REQUEST + } Self::RegistrationDisabled => StatusCode::FORBIDDEN, }; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 00e0c34d..901746d4 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -80,7 +80,9 @@ pub fn router() -> Router { "/auth", Router::new() .route("/login", post(auth::login)) - .route("/create", post(auth::create)), + .route("/create", post(auth::create)) + .route("/request-code", post(auth::handle_request_login_code)) + .route("/exchange-code", post(auth::handle_exchange_login_code)), ) // Leaderboard routing .nest( diff --git a/src/services/sessions.rs b/src/services/sessions.rs index da3004d8..ac335172 100644 --- a/src/services/sessions.rs +++ b/src/services/sessions.rs @@ -6,12 +6,18 @@ use crate::utils::hashing::IntHashMap; use crate::utils::signing::SigningKey; use crate::utils::types::PlayerID; use base64ct::{Base64UrlUnpadded, Encoding}; +use hashbrown::HashMap; use parking_lot::Mutex; +use rand::distributions::Distribution; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; type SessionMap = IntHashMap; +pub type LoginCode = String; + /// Service for storing links to authenticated sessions and /// functionality for authenticating sessions pub struct Sessions { @@ -22,27 +28,90 @@ pub struct Sessions { /// warrant the need for the async variant sessions: Mutex, + /// Mapping between generated login codes and the user the code + /// will login + login_codes: Mutex>, + /// HMAC key used for computing signatures key: SigningKey, } +pub struct LoginCodeData { + /// ID of the player the code is for + player_id: PlayerID, + /// Timestamp when the code expires + exp: SystemTime, +} + /// Unique ID given to clients before connecting so that session /// connections can be associated with network tunnels without /// relying on IP addresses: https://github.com/PocketRelay/Server/issues/64#issuecomment-1867015578 pub type AssociationId = Uuid; +/// Rand distribution for a logic code part +struct LoginCodePart; + +impl Distribution for LoginCodePart { + fn sample(&self, rng: &mut R) -> char { + let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let idx = rng.gen_range(0..chars.len()); + chars[idx] as char + } +} impl Sessions { /// Expiry time for tokens const EXPIRY_TIME: Duration = Duration::from_secs(60 * 60 * 24 * 30 /* 30 Days */); + /// Expiry time for tokens + const LOGIN_CODE_EXPIRY_TIME: Duration = Duration::from_secs(60 * 30 /* 30 minutes */); + /// Starts a new service returning its link pub fn new(key: SigningKey) -> Self { Self { sessions: Default::default(), + login_codes: Default::default(), key, } } + /// Creates a new login code for the provider player, returns the + /// login code storing the data so it can be exchanged + pub fn create_login_code(&self, player_id: PlayerID) -> Result { + let rng = StdRng::from_entropy(); + + let code: LoginCode = rng + .sample_iter(&LoginCodePart) + .take(5) + .map(char::from) + .collect(); + + // Compute expiry timestamp + let exp = SystemTime::now() + .checked_add(Self::LOGIN_CODE_EXPIRY_TIME) + .expect("Expiry timestamp too far into the future"); + + // Store the code so they can login + self.login_codes + .lock() + .insert(code.clone(), LoginCodeData { player_id, exp }); + + Ok(code) + } + + /// Exchanges a login code for a token to the player the code was for + /// if the token is not expired + pub fn exchange_login_code(&self, login_code: &LoginCode) -> Option { + let data = self.login_codes.lock().remove(login_code)?; + + // Login code is expired + if data.exp.lt(&SystemTime::now()) { + return None; + } + + let token = self.create_token(data.player_id); + Some(token) + } + /// Creates a new association token pub fn create_assoc_token(&self) -> String { let uuid = Uuid::new_v4();