1
0
mirror of https://git.ngram.ca/OpenJam/rc-servers synced 2026-08-23 23:08:52 +00:00

Move Robocraft auth server to actix_web for better error responses #31

This commit is contained in:
NG (Graham)
2025-07-27 16:12:20 -04:00
parent 796304bc4a
commit 89b1db35ba
23 changed files with 455 additions and 211 deletions

24
rc_auth/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "rc_auth"
version.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
[dependencies]
actix-web.workspace = true
actix-files.workspace = true
log.workspace = true
env_logger.workspace = true
tokio = { version = "1.43", features = [ "rt-multi-thread" ] }
clap.workspace = true
oj_rc_core = { version = "*", path = "../rc_core" }
libfj.workspace = true
git-version.workspace = true
serde.workspace = true
serde_json.workspace = true
hex = "0.4"
handlebars = { version = "5", features = ["dir_source"] }

3
rc_auth/run_debug.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
RUST_LOG=debug cargo run

31
rc_auth/src/cli.rs Normal file
View File

@@ -0,0 +1,31 @@
use clap::Parser;
#[derive(Parser, Debug, Clone)]
#[command(version, about, long_about = None)]
pub struct CliArgs {
/// TCP port on which to accept connections
#[arg(short, long, default_value_t = 8001)]
pub port: u16,
/// IP Address on which to accept connections
#[arg(long, default_value_t = {"127.0.0.1".to_string()})]
pub ip: String,
/// Assets root
#[arg(long, default_value_t = {"../assets/robocraft".to_string()})]
pub assets_robocraft: String,
/// User data root
#[arg(long, default_value_t = {"../data/robocraft".to_string()})]
pub data_robocraft: String,
}
impl CliArgs {
pub fn get() -> Self {
Self::parse()
}
pub async fn preloaded(self) -> crate::robocraft::RcConfig {
crate::robocraft::RcConfig::from_args(&self).await
}
}

60
rc_auth/src/main.rs Normal file
View File

@@ -0,0 +1,60 @@
mod cli;
mod robocraft;
use actix_web::{App, HttpServer, Responder};
#[actix_web::get("/")]
async fn index() -> impl Responder {
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
let git_version = git_version::git_version!(args = ["--always", "--dirty=+"]);
let authors = env!("CARGO_PKG_AUTHORS");
let license = env!("CARGO_PKG_LICENSE");
let repo = env!("CARGO_PKG_REPOSITORY");
format!("{} {}:{} by [{}]\n{}\n{}", name, version, git_version, authors, license, repo)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
let cli_args = cli::CliArgs::get();
let cli_args2 = actix_web::web::Data::new(cli_args.clone());
let rc_preloaded = actix_web::web::Data::new(cli_args.clone().preloaded().await);
let mut handlebars = handlebars::Handlebars::new();
handlebars
.register_templates_directory(
std::path::PathBuf::from(&cli_args.assets_robocraft).parent().expect("Bad robocraft asset path").join("templates"),
handlebars::DirectorySourceOptions {
tpl_extension: ".html.hbs".to_owned(),
hidden: false,
temporary: false,
},
)
.unwrap();
let handlebars_ref = actix_web::web::Data::new(handlebars);
HttpServer::new(move || {
App::new()
.app_data(cli_args2.clone())
.app_data(rc_preloaded.clone())
.app_data(handlebars_ref.clone())
.service(index)
.service(robocraft::registration::form_submit)
.service(robocraft::registration::form_load)
.service(robocraft::registration::favicon)
.service(robocraft::email::email_password_auth)
.service(robocraft::steam::steam_auth)
.service(robocraft::username::user_password_auth)
/*.service(robocraft::live_data::live_data_json)
.service(robocraft::user_avatar::get)
.service(robocraft::clan_avatar::get)
.service(robocraft::brawl_data::get)
.service(robocraft::campaign_data::get)
.service(robocraft::factory::arc::get)
.service(robocraft::favicon::get)*/
})
.bind((cli_args.ip, cli_args.port))?
.run()
.await
}

View File

@@ -0,0 +1,25 @@
use oj_rc_core::UserAuthenticator;
use actix_web::{post, web::{Data, Json}};
#[post("/authenticate/email/game")]
pub async fn email_password_auth(body: Json<libfj::robocraft::EmailUserAuthenticationPayload>, config: Data<super::RcConfig>) -> Result<Json<libfj::robocraft::AuthenticationResponseInfo>, super::ErrorTy> {
log::info!("Authenticating {} user {}", body.target, body.display_name);
let payload = libfj::robocraft::TokenPayload {
public_id: body.display_name.clone(),
display_name: body.display_name.clone(),
robocraft_name: body.display_name.clone(),
email_address: body.email_address.clone(),
email_verified: true,
flags: Vec::new(),
};
let user_info = oj_rc_core::persist::user::UserInfo {
payload,
extra: oj_rc_core::persist::user::ExtraUserInfo::Email { password: body.password.clone() },
};
let response = config.account_provider.login(user_info).await
.map_err(|e| {
log::error!("Failed to authenticate {} user {}: {}", body.target, body.display_name, e.message);
super::ErrorTy::from_err(e)
})?;
Ok(Json(response.response))
}

View File

@@ -0,0 +1,63 @@
pub mod email;
pub mod registration;
pub mod steam;
pub mod username;
pub struct RcConfig {
//pub data: std::path::PathBuf,
pub account_provider: oj_rc_core::UserImpl,
pub assets: std::path::PathBuf,
}
impl RcConfig {
pub async fn from_args(args: &crate::cli::CliArgs) -> Self {
let conf = oj_rc_core::persist::config::ConfigImpl::load(&args.assets_robocraft).expect("Bad config data");
Self {
account_provider: oj_rc_core::UserImpl::load(&args.data_robocraft, &conf).await.expect("Invalid Robocraft user data"),
//data: args.data_robocraft.clone().into(),
assets: args.assets_robocraft.clone().into(),
}
}
}
pub(self) struct ErrorTy {
json: libfj::robocraft::ErrorInfo,
}
impl ErrorTy {
pub fn from_err(error: oj_rc_core::persist::user::AuthError) -> Self {
Self {
json: libfj::robocraft::ErrorInfo {
error_code: error.code.to_str(),
error_message: error.message,
},
}
}
}
impl actix_web::error::ResponseError for ErrorTy {
fn status_code(&self) -> actix_web::http::StatusCode {
actix_web::http::StatusCode::UNAUTHORIZED
}
fn error_response(&self) -> actix_web::HttpResponse<actix_web::body::BoxBody> {
actix_web::HttpResponse::with_body(
self.status_code(),
serde_json::to_string(&self.json).unwrap(),
).map_into_boxed_body()
}
}
impl core::fmt::Display for ErrorTy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
//use core::fmt::Write;
write!(f, "({}) {}", self.json.error_code, self.json.error_message)
}
}
impl core::fmt::Debug for ErrorTy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ErrorTy")
.finish_non_exhaustive()
}
}

View File

@@ -0,0 +1,197 @@
use oj_rc_core::UserAuthenticator;
use actix_web::{get, post, web::{Data, Form, Html}, Responder};
//use rocket_dyn_templates::{Template, context};
use serde::{Serialize, Deserialize};
const FORM_NAME: &str = "rc_register";
const FORM_NAME_SUCCESS: &str = "rc_register_success";
const VALID_CHARS: &[char] = &[
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'_',
];
#[derive(Serialize, Deserialize)]
struct RegisterForm {
display_name: String,
password: String,
password_c: String,
email: Option<String>,
steam_id: Option<String>,
}
#[derive(Serialize)]
struct Context {
form: RegisterForm,
error: Option<String>,
version: String,
source_url: String,
}
#[derive(Serialize)]
struct ContextSuccess {
display_name: String,
id: i32,
version: String,
source_url: String,
}
fn version_string() -> String {
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
//let license = env!("CARGO_PKG_LICENSE");
//let repo = env!("CARGO_PKG_REPOSITORY");
format!("OpenJam {} {}", name, version)
}
fn all_valid_chars(s: &str) -> bool {
for c in s.chars() {
if !VALID_CHARS.contains(&c) {
return false;
}
}
true
}
fn registration_ok(form: RegisterForm, renderer: &handlebars::Handlebars<'_>) -> Html {
let rendered = renderer.render(FORM_NAME, &Context {
form,
error: None,
version: version_string(),
source_url: env!("CARGO_PKG_REPOSITORY").to_owned(),
}).unwrap();
Html::new(rendered)
}
fn registration_err(form: RegisterForm, error: String , renderer: &handlebars::Handlebars<'_>) -> Html {
let rendered = renderer.render(FORM_NAME, &Context {
form,
error: Some(error),
version: version_string(),
source_url: env!("CARGO_PKG_REPOSITORY").to_owned(),
}).unwrap();
Html::new(rendered)
}
#[post("/register")]
pub async fn form_submit(form: Form<RegisterForm>, config: Data<super::RcConfig>, handlebars_ref: Data<handlebars::Handlebars<'_>>) -> Result<Html, actix_web::error::Error> {
// password confirmation validation
if form.password != form.password_c {
return Ok(registration_err(form.into_inner(), "Passwords do not match".to_owned(), &*handlebars_ref));
}
if form.password.len() < 8 {
return Ok(registration_err(form.into_inner(), "Password too short (minimum 8 characters)".to_owned(), &*handlebars_ref));
}
if form.password.len() > 128 {
return Ok(registration_err(form.into_inner(), "Password too long (maximum 128 characters)".to_owned(), &*handlebars_ref));
}
// email validation
let actual_email: Option<String>;
if let Some(email) = &form.email {
if email.is_empty() {
actual_email = None;
} else {
if !email.contains('@') {
return Ok(registration_err(form.into_inner(), "Email must contain @".to_owned(), &*handlebars_ref));
}
let email_exists = config.account_provider.user_exists(oj_rc_core::persist::user::UserId::Email(email.to_owned()))
.await
.map_err(|e| {
log::error!("Failed to check if user email {} exists: {}", email, e);
actix_web::error::ErrorInternalServerError(e)
})?;
if email_exists {
return Ok(registration_err(form.into_inner(), "Email already registered".to_owned(), &*handlebars_ref));
}
actual_email = Some(email.to_owned());
}
} else {
actual_email = None;
}
// steam id validation
let actual_steam_id: Option<u64>;
if let Some(steam_id) = &form.steam_id {
if steam_id.is_empty() {
actual_steam_id = None;
} else {
let steam_id = match steam_id.parse() {
Ok(id) => id,
Err(_e) => return Ok(registration_err(form.into_inner(), "Invalid SteamID (not an integer)".to_owned(), &*handlebars_ref)),
};
if steam_id >= 7656120_0000000000 || steam_id < 7656119_0000000000 {
return Ok(registration_err(form.into_inner(), "Invalid SteamID (should be like 7656119XXXXXXXXXX)".to_owned(), &*handlebars_ref));
}
let steam_exists = config.account_provider.user_exists(oj_rc_core::persist::user::UserId::SteamId(steam_id))
.await
.map_err(|e| {
log::error!("Failed to check if user steam id {} exists: {}", steam_id, e);
actix_web::error::ErrorInternalServerError(e)
})?;
if steam_exists {
return Ok(registration_err(form.into_inner(), "SteamID already registered".to_owned(), &*handlebars_ref));
}
actual_steam_id = Some(steam_id);
}
} else {
actual_steam_id = None;
}
// username validation
if form.display_name.len() < 4 {
return Ok(registration_err(form.into_inner(), "Username too short (minimum 4 characters)".to_owned(), &*handlebars_ref));
}
if form.display_name.len() > 32 {
return Ok(registration_err(form.into_inner(), "Username too long (maximum 32 characters)".to_owned(), &*handlebars_ref));
}
if !all_valid_chars(&form.display_name.to_lowercase()) {
return Ok(registration_err(form.into_inner(), "Invalid username (only alphanumerics and _ allowed)".to_owned(), &*handlebars_ref));
}
let username_exists = config.account_provider.user_exists(oj_rc_core::persist::user::UserId::Username(form.display_name.to_owned()))
.await
.map_err(|e| {
log::error!("Failed to check if user name {} exists: {}", form.display_name, e);
actix_web::error::ErrorInternalServerError(e)
})?;
if username_exists {
return Ok(registration_err(form.into_inner(), "Username already registered".to_owned(), &*handlebars_ref));
}
let user_id = match config.account_provider.register(oj_rc_core::persist::user::RegistrationInfo {
display_name: form.display_name.clone(),
password: form.password.clone(),
email: actual_email,
steam_id: actual_steam_id,
}).await {
Ok(id) => id,
Err(e) => {
return Ok(registration_err(form.into_inner(), format!("Registration failed: {}", e), &*handlebars_ref));
}
};
Ok(Html::new(handlebars_ref.render(FORM_NAME_SUCCESS, &ContextSuccess {
display_name: form.display_name.clone(),
id: user_id,
version: version_string(),
source_url: env!("CARGO_PKG_REPOSITORY").to_owned(),
}).unwrap()))
}
#[get("/register")]
pub async fn form_load(handlebars_ref: Data<handlebars::Handlebars<'_>>) -> Html {
registration_ok(RegisterForm {
display_name: "".to_owned(),
password: "".to_owned(),
password_c: "".to_owned(),
email: None,
steam_id: None,
}, &*handlebars_ref)
}
#[get("/robocraft/favicon")]
pub async fn favicon(config: Data<super::RcConfig>) -> impl Responder {
let path = config.assets.join("favicon.jpg");
actix_files::NamedFile::open_async(path).await
}

View File

@@ -0,0 +1,65 @@
use oj_rc_core::UserAuthenticator;
use actix_web::{post, web::{Data, Json}};
fn authenticate_steam_ticket(hex_ticket: &str) -> Result<u64, ()> {
get_steam_id_from_ticket_hex(hex_ticket)
.map_err(|e| {
log::error!("Failed to parse steamId: {}", e);
()
})
}
fn get_steam_id_from_ticket_hex(hex_ticket: &str) -> Result<u64, hex::FromHexError> {
let decoded_ticket = hex::decode(hex_ticket)?;
if decoded_ticket.len() < 72 {
Err(hex::FromHexError::InvalidStringLength)
} else {
Ok(get_steam_id_from_ticket(&decoded_ticket))
}
}
fn get_steam_id_from_ticket(ticket: &[u8]) -> u64 {
get_u64_with_offset(&ticket, 12 /* also at 64 ??? */) // should be 76600000000000000 > number > 76500000000000000
}
fn get_u64_with_offset(arr: &[u8], start: usize) -> u64 {
let arr_actual: [u8; 8] = [
arr[start],
arr[start+1],
arr[start+2],
arr[start+3],
arr[start+4],
arr[start+5],
arr[start+6],
arr[start+7],
];
u64::from_le_bytes(arr_actual)
}
#[post("/authenticate/steam/game")]
pub async fn steam_auth(body: Json<libfj::robocraft::SteamAuthenticationPayload>, config: Data<super::RcConfig>) -> Result<Json<libfj::robocraft::AuthenticationResponseInfo>, super::ErrorTy> {
let steam_id = authenticate_steam_ticket(&body.steam_ticket)
.map_err(|_| super::ErrorTy::from_err(oj_rc_core::persist::user::AuthError {
message: "Bad SteamId".to_owned(),
code: oj_rc_core::data::error_codes::AuthErrorCode::BadCredentials,
}))?;
log::info!("Authenticating {} steam user {}", body.target, steam_id);
let payload = libfj::robocraft::TokenPayload {
public_id: steam_id.to_string(),
display_name: steam_id.to_string(),
robocraft_name: steam_id.to_string(),
email_address: format!("{}.rc.steam@ngram.ca", steam_id),
email_verified: true,
flags: Vec::new(),
};
let user_info = oj_rc_core::persist::user::UserInfo {
payload,
extra: oj_rc_core::persist::user::ExtraUserInfo::Steam { id: steam_id },
};
let response = config.account_provider.login(user_info).await
.map_err(|e| {
log::error!("Failed to authenticate {} steam user {}: {}", body.target, steam_id, e.message);
super::ErrorTy::from_err(e)
})?;
Ok(Json(response.response))
}

View File

@@ -0,0 +1,25 @@
use oj_rc_core::UserAuthenticator;
use actix_web::{post, web::{Data, Json}};
#[post("/authenticate/robocraft/game")]
pub async fn user_password_auth(body: Json<libfj::robocraft::EmailUserAuthenticationPayload>, config: Data<super::RcConfig>) -> Result<Json<libfj::robocraft::AuthenticationResponseInfo>, super::ErrorTy> {
log::info!("Authenticating {} user {}", body.target, body.display_name);
let payload = libfj::robocraft::TokenPayload {
public_id: body.display_name.clone(),
display_name: body.display_name.clone(),
robocraft_name: body.display_name.clone(),
email_address: body.email_address.clone(),
email_verified: true,
flags: Vec::new(),
};
let user_info = oj_rc_core::persist::user::UserInfo {
payload,
extra: oj_rc_core::persist::user::ExtraUserInfo::Username { password: body.password.clone() },
};
let response = config.account_provider.login(user_info).await
.map_err(|e| {
log::error!("Failed to authenticate {} user {}: {}", body.target, body.display_name, e.message);
super::ErrorTy::from_err(e)
})?;
Ok(Json(response.response))
}