Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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