Skip to content

Commit

Permalink
Merge pull request #71 from PocketRelay/feat-deep-link
Browse files Browse the repository at this point in the history
feat: system messages, login codes
  • Loading branch information
jacobtread committed Jun 18, 2024
2 parents 581e924 + e4ca7e4 commit e152e0a
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 7 deletions.
116 changes: 110 additions & 6 deletions src/routes/auth.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<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 : "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,
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<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,
};

Expand Down
4 changes: 3 additions & 1 deletion src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
69 changes: 69 additions & 0 deletions src/services/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlayerID, WeakSessionLink>;

pub type LoginCode = String;

/// Service for storing links to authenticated sessions and
/// functionality for authenticating sessions
pub struct Sessions {
Expand All @@ -22,27 +28,90 @@ 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
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 */);

/// 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 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<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();
Expand Down

0 comments on commit e152e0a

Please sign in to comment.