From bb18336c81eb62bc9aa1e471cde256a0dab97ac0 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Sun, 16 Jun 2024 20:55:02 +1200
Subject: [PATCH 1/5] feat: login code and system message impl

---
 src/routes/auth.rs       | 112 ++++++++++++++++++++++++++++++++++++---
 src/routes/mod.rs        |   4 +-
 src/services/sessions.rs |  50 +++++++++++++++++
 3 files changed, 159 insertions(+), 7 deletions(-)

diff --git a/src/routes/auth.rs b/src/routes/auth.rs
index ab912b33..f15ed82c 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,100 @@ 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,
+}
+
+/// 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<DatabaseConnection>,
+    Extension(sessions): Extension<Arc<Sessions>>,
+    Json(RequestLoginCodeRequest { email }): Json<RequestLoginCodeRequest>,
+) -> Result<StatusCode, AuthError> {
+    // 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 : "Origin Confirmation Code".to_string(),
+        message: format!("You Origin confirmation code is <font color='#FFFF66'>{login_code}</font>, enter this on the dashboard to login"),
+        image: "".to_string(),
+        ty:0, 
+        tracking_id: -1,
+        priority: 1
+    }).unwrap();
+    
+    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,
+}
+
+/// 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<Arc<Sessions>>,
+    Json(RequestExchangeLoginCode { login_code }): Json<RequestExchangeLoginCode>,
+) -> AuthRes<TokenResponse>  {
+
+    // 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..690aa6e2 100644
--- a/src/services/sessions.rs
+++ b/src/services/sessions.rs
@@ -6,12 +6,15 @@ 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 std::time::{Duration, SystemTime, UNIX_EPOCH};
 use uuid::Uuid;
 
 type SessionMap = IntHashMap<PlayerID, WeakSessionLink>;
 
+pub type LoginCode = String;
+
 /// Service for storing links to authenticated sessions and
 /// functionality for authenticating sessions
 pub struct Sessions {
@@ -22,10 +25,21 @@ pub struct Sessions {
     /// warrant the need for the async variant
     sessions: Mutex<SessionMap>,
 
+    /// Mapping between generated login codes and the user the code
+    /// will login
+    login_codes: Mutex<HashMap<LoginCode, LoginCodeData>>,
+
     /// 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
@@ -35,14 +49,50 @@ 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<LoginCode, ()> {
+        let code: LoginCode = "ACC123".to_string();
+
+        // 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<String> {
+        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();

From f0e2303fd2971b6e2873807baffe1c8b3938e5b4 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Sun, 16 Jun 2024 20:59:37 +1200
Subject: [PATCH 2/5] fix: change login code message

---
 src/routes/auth.rs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/routes/auth.rs b/src/routes/auth.rs
index f15ed82c..86210db1 100644
--- a/src/routes/auth.rs
+++ b/src/routes/auth.rs
@@ -192,8 +192,8 @@ pub async fn handle_request_login_code(
 
     // Create and serialize the message
     let origin_message = serde_json::to_string(&SystemMessage {
-        title : "Origin Confirmation Code".to_string(),
-        message: format!("You Origin confirmation code is <font color='#FFFF66'>{login_code}</font>, enter this on the dashboard to login"),
+        title : "Login Confirmation Code".to_string(),
+        message: format!("Your login confirmation code is <font color='#FFFF66'>{login_code}</font>, enter this on the dashboard to login"),
         image: "".to_string(),
         ty:0, 
         tracking_id: -1,

From 3d79b674cec3732efb5da472e9816d55f5aa2f14 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Sun, 16 Jun 2024 21:16:03 +1200
Subject: [PATCH 3/5] feat: randomly generated login codes

---
 src/services/sessions.rs | 21 ++++++++++++++++++++-
 1 file changed, 20 insertions(+), 1 deletion(-)

diff --git a/src/services/sessions.rs b/src/services/sessions.rs
index 690aa6e2..ac335172 100644
--- a/src/services/sessions.rs
+++ b/src/services/sessions.rs
@@ -8,6 +8,9 @@ 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;
 
@@ -45,6 +48,16 @@ pub struct LoginCodeData {
 /// 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<char> for LoginCodePart {
+    fn sample<R: Rng + ?Sized>(&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 */);
@@ -64,7 +77,13 @@ impl Sessions {
     /// 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<LoginCode, ()> {
-        let code: LoginCode = "ACC123".to_string();
+        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()

From 0547ba24b195c0350ce425aca22e56d85f218685 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Mon, 17 Jun 2024 20:59:53 +1200
Subject: [PATCH 4/5] docs: add method and path to new auth url doc comments

---
 src/routes/auth.rs | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/routes/auth.rs b/src/routes/auth.rs
index 86210db1..f76584ea 100644
--- a/src/routes/auth.rs
+++ b/src/routes/auth.rs
@@ -170,6 +170,8 @@ pub struct RequestLoginCodeRequest {
     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(
@@ -233,6 +235,8 @@ pub struct RequestExchangeLoginCode {
     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(

From e4ca7e489a0573a837cd13ddd2790b3314494ff1 Mon Sep 17 00:00:00 2001
From: Jacobtread <jacobtread@gmail.com>
Date: Tue, 18 Jun 2024 19:37:26 +1200
Subject: [PATCH 5/5] fix: remove unwrap from login message serialization

---
 src/routes/auth.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/routes/auth.rs b/src/routes/auth.rs
index f76584ea..56562de8 100644
--- a/src/routes/auth.rs
+++ b/src/routes/auth.rs
@@ -200,7 +200,7 @@ pub async fn handle_request_login_code(
         ty:0, 
         tracking_id: -1,
         priority: 1
-    }).unwrap();
+    }).map_err(|_|AuthError::FailedGenerateCode)?;
     
     let notify_origin = Packet::notify(
         messaging::COMPONENT,
