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

Implement federated login; #123

This commit is contained in:
NG (Graham)
2026-07-05 15:45:36 -04:00
committed by NGnius
parent 6601c188d0
commit 2adf8249bb
35 changed files with 1466 additions and 121 deletions

View File

@@ -27,10 +27,14 @@ indexmap = { version = "2.0", features = ["serde"] }
libfj.workspace = true
jsonwebtoken = { version = "10", features = [ "rust_crypto" ] }
argon2 = { version = "0.5", features = [ "std" ] }
# federated auth
openidconnect.workspace = true
ring = "0.17"
urlencoding.workspace = true
# intercom
sha2 = "0.11"
reqwest = { version = "0.13", default-features = false, features = [ "rustls", "charset", "json" ] }
reqwest = { version = "0.13", default-features = false, features = [ "rustls", "charset", "json", "form" ] }
reqwest-websocket = { version = "0.6", default-features = false, features = [ "json" ] }
oj_serdes.workspace = true

View File

@@ -7,7 +7,14 @@ pub struct Token {
pub federate: bool,
pub auth_time: i64,
pub qualified_name: String,
pub source_domain: String,
pub login_method: LoginMethod,
pub iss: String,
pub exp: i64,
pub iat: i64,
pub sub: String,
pub aud: String,
pub fedi_token: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Copy)]
@@ -16,4 +23,5 @@ pub enum LoginMethod {
DisplayName,
Username,
Email,
OAuth,
}

View File

@@ -327,6 +327,7 @@ impl <C: Clone + Send> super::ConfigProvider<C> for CubeConfig {
auth_url: self.settings.server.auth_url.trim_end_matches('/').to_owned(),
intercom_url: self.settings.server.intercom_url.trim_end_matches('/').to_owned(),
factory_url: self.settings.server.factory_url.trim_end_matches('/').to_owned(),
society_url: self.settings.server.society_url.trim_end_matches('/').to_owned(),
minimum_version: self.settings.server.min_version as i32,
dos_protect: self.settings.server.dos_protection,
maintenance_message: self.settings.server.maintenance_message.clone(),

View File

@@ -105,6 +105,7 @@ pub struct ServerConfig {
pub auth_url: String,
pub intercom_url: String,
pub factory_url: String,
pub society_url: String,
pub minimum_version: i32,
pub dos_protect: bool,
pub maintenance_message: Option<String>,

View File

@@ -23,11 +23,16 @@ fn default_aliases() -> std::collections::HashMap<String, String> {
alias_map.insert("rc.ngram.ca".to_owned(), "society.rc.ngram.ca".to_owned());
alias_map.insert("robocraft.online".to_owned(), "society.robocraft.online".to_owned());
alias_map.insert("robocraftgame.co.uk".to_owned(), "society.robocraftgame.co.uk".to_owned());
alias_map.insert("127.0.0.1".to_owned(), "127.0.0.1:8002".to_owned());
alias_map
}
fn default_defederated() -> Vec<String> {
vec![
"robocraftgame.com".to_owned(),
#[cfg(debug_assertions)]
{ "127.0.0.1:8002".to_owned() },
#[cfg(debug_assertions)]
{ "127.0.0.1".to_owned() },
]
}

View File

@@ -111,6 +111,8 @@ pub struct ServerSettings {
pub intercom_url: String,
#[serde(default = "default_factory_url")]
pub factory_url: String,
#[serde(default = "default_society_url")]
pub society_url: String,
#[serde(default = "default_feedback_url")]
pub feedback_url: String,
#[serde(default = "default_support_url")]
@@ -159,6 +161,7 @@ fn default_server_conf() -> ServerSettings {
auth_url: default_auth_root_url(),
intercom_url: default_intercom_root_url(),
factory_url: default_factory_url(),
society_url: default_society_url(),
feedback_url: default_feedback_url(),
support_url: default_support_url(),
wiki_url: default_wiki_url(),
@@ -188,6 +191,10 @@ fn default_factory_url() -> String {
"http://127.0.0.1:8012".to_owned()
}
fn default_society_url() -> String {
"http://127.0.0.1:8002".to_owned()
}
fn default_feedback_url() -> String {
"https://mstdn.ca/@ngram".to_owned()
}

View File

@@ -10,15 +10,14 @@ pub struct AccountProvider {
fake_players: std::sync::Arc<Vec<crate::persist::config::FakePlayer>>,
filler_players: std::sync::Arc<Vec<crate::persist::config::FakePlayer>>,
auto_signups: bool,
domain: std::sync::Arc<String>,
pub(super) domain: std::sync::Arc<String>,
cdn: std::sync::Arc<String>,
auth: std::sync::Arc<String>,
pub(super) auth: std::sync::Arc<String>,
pub(super) intercom: std::sync::Arc<String>,
pub(super) intercom_http_client: std::sync::Arc<reqwest::Client>,
pub(super) secret: std::sync::Arc<Vec<u8>>,
db: std::sync::Arc<oj_rc_database::Database>,
#[allow(dead_code)]
federation: Option<crate::persist::config::Federation>,
pub(super) db: std::sync::Arc<oj_rc_database::Database>,
pub(super) federation: Option<crate::persist::config::Federation>,
}
impl AccountProvider {
@@ -42,7 +41,12 @@ impl AccountProvider {
cdn: std::sync::Arc::new(server_settings.cdn_url),
auth: std::sync::Arc::new(server_settings.auth_url),
intercom: std::sync::Arc::new(server_settings.intercom_url),
intercom_http_client: std::sync::Arc::new(reqwest::Client::new()),
intercom_http_client: std::sync::Arc::new(
reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("HTTP client did not init")
),
secret: std::sync::Arc::new(secret),
db: std::sync::Arc::new(db),
federation: federation_conf,
@@ -90,6 +94,7 @@ impl AccountProvider {
let secret = jsonwebtoken::DecodingKey::from_secret(&self.secret);
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.set_required_spec_claims::<&str>(&[]);
validation.aud = Some(vec![ self.domain.to_string() ].into_iter().collect());
let token_data = jsonwebtoken::decode::<crate::auth::Token>(&token, &secret, &validation).map_err(|e| super::AuthError {
message: e.to_string(),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
@@ -134,62 +139,10 @@ impl AccountProvider {
secret: self.secret.clone(),
})
}
}
#[async_trait::async_trait]
impl <C: Clone + Send> super::UserProvider<C> for AccountProvider {
async fn authenticate(&self, token: super::UserToken) -> Result<Box<dyn super::User<C> + Send + Sync>, super::AuthError> {
Ok(Box::new(self.auth_internal(&token.token).await?))
}
async fn multiplayer_authenticate(&self, user: String) -> Result<Box<dyn super::User<C> + Send + Sync>, super::AuthError> {
let user_info = if let Some(user_info) = self.db.user_by_display_name(user).await.map_err(|e| super::AuthError {
message: e.to_string(),
code: crate::data::error_codes::AuthErrorCode::Unknown,
})? {
user_info
} else {
return Err(super::AuthError {
message: "User not found".to_owned(),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
};
let user_perms = if let Some(user_perms) = self.db.perms_by_user_id(user_info.id).await.map_err(|e| super::AuthError {
message: e.to_string(),
code: crate::data::error_codes::AuthErrorCode::Unknown,
})? {
user_perms
} else {
return Err(super::AuthError {
message: "User permissions not found".to_owned(),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
};
Ok(Box::new(UserData {
account: user_info,
perms: user_perms,
cubes: self.cubes.clone(),
garage_upgrades: self.garage_upgrades.clone(),
fake_players: self.fake_players.clone(),
filler_players: self.filler_players.clone(),
cdn: self.cdn.clone(),
auth: self.auth.clone(),
intercom: self.intercom.clone(),
http_client: std::sync::Arc::new(reqwest::Client::new()),
db: self.db.clone(),
secret: self.secret.clone(),
}))
}
async fn web_authenticate(&self, token: String) -> Result<Box<dyn super::WebUser>, super::AuthError> {
Ok(Box::new(self.auth_internal(&token).await?))
}
}
#[async_trait::async_trait]
impl super::UserAuthenticator for AccountProvider {
async fn login(&self, info: super::UserAuthInfo) -> Result<super::UserLoginInfo, super::AuthError> {
pub(super) async fn login_internal(&self, info: super::UserAuthInfo, audience: Option<String>) -> Result<super::UserLoginInfo, super::AuthError> {
//let new_root = self.root.join(&info.payload.public_id);
let is_fedi = audience.is_some();
let is_new_user;
let user_opt = match &info {
super::UserAuthInfo::Steam { id } => self.db.user_by_steam_id(*id).await,
@@ -204,7 +157,7 @@ impl super::UserAuthenticator for AccountProvider {
user_info
} else {
is_new_user = true;
if self.auto_signups {
if self.auto_signups && !is_fedi {
log::info!("New user {}", info.display_id());
let auto_name = match &info {
super::UserAuthInfo::Steam { id } => id.to_string(),
@@ -306,22 +259,31 @@ impl super::UserAuthenticator for AccountProvider {
super::UserAuthInfo::Username { .. } => crate::auth::LoginMethod::Username,
};
let pub_id = if is_fedi { format!("{}#{}", user_info.public_id, self.domain) } else { user_info.public_id.clone() };
let client_details = libfj::robocraft::TokenPayload {
public_id: user_info.public_id.clone(),
display_name: user_info.display_name.clone(),
robocraft_name: user_info.display_name,
public_id: pub_id.clone(),
display_name: if is_fedi { format!("{}#{}", user_info.display_name, self.domain) } else { user_info.display_name.clone() },
robocraft_name: if is_fedi { format!("{}#{}", user_info.public_id, self.domain) } else { user_info.public_id.clone() },
email_address: user_info.email,
email_verified: true,
flags: vec![
"federated=false".to_owned(),
if is_fedi { "federated=true".to_owned() } else { "federated=false".to_owned() },
],
};
let now = chrono::Utc::now().timestamp();
let payload = crate::auth::Token {
client_details,
federate: false,
auth_time: chrono::Utc::now().timestamp(),
qualified_name: format!("{}@{}", user_info.public_id, self.domain),
login_method,
federate: is_fedi,
auth_time: now,
qualified_name: format!("{}#{}", user_info.public_id, self.domain),
source_domain: self.domain.to_string(),
login_method: if is_fedi { crate::auth::LoginMethod::OAuth } else { login_method },
iss: self.auth.to_string(),
exp: now + 86400, // 1 day
iat: now,
sub: pub_id.clone(),
aud: audience.unwrap_or_else(|| self.domain.to_string()),
fedi_token: None,
};
#[cfg(debug_assertions)]
log::debug!("Token payload\n{}", serde_json::to_string_pretty(&payload).unwrap());
@@ -341,6 +303,63 @@ impl super::UserAuthenticator for AccountProvider {
is_new: is_new_user,
})
}
}
#[async_trait::async_trait]
impl <C: Clone + Send> super::UserProvider<C> for AccountProvider {
async fn authenticate(&self, token: super::UserToken) -> Result<Box<dyn super::User<C> + Send + Sync>, super::AuthError> {
Ok(Box::new(self.auth_internal(&token.token).await?))
}
async fn multiplayer_authenticate(&self, user: String) -> Result<Box<dyn super::User<C> + Send + Sync>, super::AuthError> {
let user_info = if let Some(user_info) = self.db.user_by_display_name(user).await.map_err(|e| super::AuthError {
message: e.to_string(),
code: crate::data::error_codes::AuthErrorCode::Unknown,
})? {
user_info
} else {
return Err(super::AuthError {
message: "User not found".to_owned(),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
};
let user_perms = if let Some(user_perms) = self.db.perms_by_user_id(user_info.id).await.map_err(|e| super::AuthError {
message: e.to_string(),
code: crate::data::error_codes::AuthErrorCode::Unknown,
})? {
user_perms
} else {
return Err(super::AuthError {
message: "User permissions not found".to_owned(),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
};
Ok(Box::new(UserData {
account: user_info,
perms: user_perms,
cubes: self.cubes.clone(),
garage_upgrades: self.garage_upgrades.clone(),
fake_players: self.fake_players.clone(),
filler_players: self.filler_players.clone(),
cdn: self.cdn.clone(),
auth: self.auth.clone(),
intercom: self.intercom.clone(),
http_client: std::sync::Arc::new(reqwest::Client::new()),
db: self.db.clone(),
secret: self.secret.clone(),
}))
}
async fn web_authenticate(&self, token: String) -> Result<Box<dyn super::WebUser>, super::AuthError> {
Ok(Box::new(self.auth_internal(&token).await?))
}
}
#[async_trait::async_trait]
impl super::UserAuthenticator for AccountProvider {
async fn login(&self, info: super::UserAuthInfo) -> Result<super::UserLoginInfo, super::AuthError> {
self.login_internal(info, None).await
}
async fn user_exists(&self, user: super::UserId) -> Result<bool, String> {
Ok(match user {

View File

@@ -1,5 +1,12 @@
use openidconnect::{OAuth2TokenResponse, TokenResponse};
use serde::{Serialize, Deserialize};
const SOCIETY_URLS_API_ENDPOINT: &'static str = "api/v1/services.json";
const ACCESS_CODE_AAD: &'static [u8] = b"oj-access-code";
pub type DiscoveryMetadata = openidconnect::core::CoreProviderMetadata;
pub type TokenResponsePayload = openidconnect::core::CoreTokenResponse;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Federation {
pub enabled: bool,
@@ -14,3 +21,557 @@ impl std::default::Default for Federation {
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct FederatedAuthenticationPayload {
pub display_name: String,
pub password: String,
pub domain_source: String,
pub domain_target: String,
}
#[derive(Deserialize, Serialize)]
struct AccessCode {
aud: String,
exp: i64,
iat: i64,
iss: String,
sub: String,
secured: String,
}
#[derive(Deserialize, Serialize)]
struct SecuredCodes {
access_token: String,
refresh_token: String,
code_challenge: String,
}
struct NonceProvider {
issuer: std::sync::Arc<String>,
secret: std::sync::Arc<Vec<u8>>,
generated_time: i64,
fuse: bool,
}
impl NonceProvider {
fn reset(&mut self, iat: i64) {
self.generated_time = iat;
self.fuse = false;
}
}
impl ring::aead::NonceSequence for NonceProvider {
// realistically this shouldn't ever be called again
fn advance(&mut self) -> Result<ring::aead::Nonce, ring::error::Unspecified> {
if self.fuse {
Err(ring::error::Unspecified)
} else {
use sha2::Digest;
self.fuse = true;
let hash = sha2::Sha512::new()
.chain_update(self.issuer.as_bytes())
.chain_update(self.secret.as_slice())
.chain_update(&self.generated_time.to_ne_bytes())
.finalize();
let mut nonce = Vec::from(hash.as_slice());
nonce.truncate(12);
Ok(ring::aead::Nonce::try_assume_unique_for_key(&nonce).unwrap())
}
}
}
impl super::AccountProvider {
fn nonce_provider(&self) -> NonceProvider {
NonceProvider {
issuer: self.auth.clone(),
secret: self.secret.clone(),
generated_time: 0,
fuse: true,
}
}
fn is_defederated_from(&self, domain: &str, fedi_conf: &crate::persist::config::Federation) -> bool {
for defederated in fedi_conf.defederated.iter() {
if domain.ends_with(defederated) {
return true;
}
}
false
}
async fn local_login_impl(&self, auth_info: super::FederatedAuthInfo, federation: &Option<crate::persist::config::Federation>) -> Result<super::UserLoginInfo, super::AuthError> {
if auth_info.display_name.is_empty() {
return Err(super::AuthError {
message: format!("Refusing federation login with empty username"),
code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName,
});
}
if let Some(fedi_conf) = federation {
if auth_info.domain.is_empty() {
return Err(super::AuthError {
message: format!("Refusing federation login with empty domain for logging in {}", auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated,
});
}
let sanitised_domain = auth_info.domain.trim().to_lowercase();
let target_domain = if let Some(alias) = fedi_conf.aliases.get(&sanitised_domain) {
alias.to_owned()
} else {
sanitised_domain
};
if self.is_defederated_from(&target_domain, fedi_conf) {
return Err(super::AuthError {
message: format!("Refusing federation with {} for logging in {}", auth_info.domain, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated,
});
}
let is_localhost = target_domain == "localhost"
|| target_domain == "::1"
|| target_domain == "0:0:0:0:0:0:0:1"
|| target_domain.starts_with("127.0.0.")
|| target_domain.starts_with("localhost:");
// TODO contact other domain
let social_urls_api = if is_localhost {
format!("http://{}/{}", target_domain, SOCIETY_URLS_API_ENDPOINT)
} else {
format!("https://{}/{}", target_domain, SOCIETY_URLS_API_ENDPOINT)
};
let urls: oj_serdes::society::ServiceDomains = self.intercom_http_client.get(&social_urls_api).send().await
.map_err(|e| {
log::error!("Failed to get {}: {}", social_urls_api, e);
super::AuthError {
message: format!("Failed to get {} for logging in {}", social_urls_api, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
}
})?
.json().await
.map_err(|e| {
log::error!("Failed to deserialize {}: {}", social_urls_api, e);
super::AuthError {
message: format!("Failed to deserialize {} for logging in {}", social_urls_api, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
}
})?;
if self.is_defederated_from(&urls.root, fedi_conf) {
return Err(super::AuthError {
message: format!("Refusing federation with root {} for logging in {}", urls.root, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated,
});
}
let sani_soc = urls.society.trim_start_matches("http://").trim_start_matches("https://").trim_matches('/').to_lowercase();
if sani_soc != target_domain {
return Err(super::AuthError {
message: format!("Bad society federation with {} for logging in {}", target_domain, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
}
let issuer_url = openidconnect::IssuerUrl::new(urls.auth.clone())
.map_err(|e| super::AuthError {
message: format!("Failed to parse issuer url {} for logging in {}: {}", urls.auth, auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
// openidconnect relies on an old dependency
let oauth_http_client = openidconnect::reqwest::ClientBuilder::new()
// Following redirects opens the client up to SSRF vulnerabilities.
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.expect("Client should build");
// access openid discovery endpoint to self-configure
let provider_metadata = DiscoveryMetadata::discover_async(issuer_url, &oauth_http_client).await
.map_err(|e| super::AuthError {
message: format!("Failed to discover OAuth on {} for logging in {}: {}", urls.auth, auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
let redirect_url_s = format!("http://{}/federation/redirect", self.domain);
let redirect_url = openidconnect::RedirectUrl::new(redirect_url_s.clone())
.map_err(|e| super::AuthError {
message: format!("Failed to parse redirect url {} for logging in {}: {}", redirect_url_s, auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
// do oauth exchange
let oauth_client = openidconnect::core::CoreClient::from_provider_metadata(
provider_metadata,
openidconnect::ClientId::new(self.domain.to_string()),
None, // no client secret
).set_redirect_uri(redirect_url);
let (pkce_challenge, pkce_verifier) = openidconnect::PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token, _nonce) = oauth_client
.authorize_url(
openidconnect::core::CoreAuthenticationFlow::AuthorizationCode,
openidconnect::CsrfToken::new_random,
openidconnect::Nonce::new_random,
)
.add_scope(openidconnect::Scope::new("read".to_string()))
.add_scope(openidconnect::Scope::new("federate".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
// bypass browser because we don't need to ask permission and there's no mechanism to open a browser
let remote_login = FederatedAuthenticationPayload {
display_name: auth_info.display_name.clone(),
password: auth_info.password.clone(),
domain_source: self.domain.to_string(),
domain_target: auth_info.domain.clone(),
};
let auth_resp = oauth_http_client.post(auth_url)
.form(&remote_login)
.send()
.await
.map_err(|e| super::AuthError {
message: format!("Failed to authenticate federated login in for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
let auth_redirected_url = if let Some(loc_header) = auth_resp.headers().get("location") {
let url = loc_header.to_str().map_err(|e| super::AuthError {
message: format!("Failed to stringify location header for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName,
})?;
reqwest::Url::parse(url).map_err(|e| super::AuthError {
message: format!("Failed to parse location URL for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName,
})?
} else {
return Err(super::AuthError {
message: format!("Bad OAuth2 auth response from {} for logging in {} (missing location header)", target_domain, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
};
//let auth_redirected_url = auth_resp.url();
log::debug!("OAuth auth response URL: {}", auth_redirected_url);
let mut query_map: std::collections::HashMap<_, _> = auth_redirected_url.query_pairs().collect();
let auth_code = if let Some(auth_code) = query_map.remove("code") {
openidconnect::AuthorizationCode::new(auth_code.to_string())
} else {
return Err(super::AuthError {
message: format!("Bad OAuth2 auth response from {} for logging in {} (missing code)", target_domain, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
};
if let Some(state) = query_map.remove("state") {
if state != csrf_token.into_secret() {
return Err(super::AuthError {
message: format!("Bad OAuth2 auth response from {} for logging in {} (invalid state)", target_domain, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
}
} else {
return Err(super::AuthError {
message: format!("Bad OAuth2 auth response from {} for logging in {} (missing state)", target_domain, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
};
let token_resp: TokenResponsePayload = oauth_client.exchange_code(auth_code)
.map_err(|e| super::AuthError {
message: format!("Failed to exchange code for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?
.set_pkce_verifier(pkce_verifier)
.request_async(&oauth_http_client).await
.map_err(|e| super::AuthError {
message: format!("Failed to exchange code for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
let _id_token = token_resp.id_token()
.ok_or_else(|| super::AuthError {
message: format!("OAuth2 Token response missing ID token for {}", auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
// Don't verify since it's not using the openidconnect::core's signing algorithm
/*let id_token_verifier = oauth_client.id_token_verifier();
let claims = id_token.claims(&id_token_verifier, &nonce)
.map_err(|e| super::AuthError {
message: format!("Failed to verify OAuth2 claims for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash = openidconnect::AccessTokenHash::from_token(
token_resp.access_token(),
id_token.signing_alg().map_err(|e| super::AuthError {
message: format!("Failed to verify OAuth2 signing algorithm for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?,
id_token.signing_key(&id_token_verifier).map_err(|e| super::AuthError {
message: format!("Failed to verify OAuth2 signing key for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?,
).map_err(|e| super::AuthError {
message: format!("Failed to verify OAuth2 access token for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(super::AuthError {
message: format!("Failed to verify OAuth2 access token for {} (no match)", auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
});
}
}*/
let refresh_token = token_resp.refresh_token()
.ok_or_else(|| super::AuthError {
message: format!("OAuth2 Token response missing refresh token for {}", auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?
.secret()
.to_owned();
let remote_token = token_resp.access_token().secret();
#[cfg(debug_assertions)]
log::debug!("User {} authenticated to {} with access token {}", auth_info.display_name, target_domain, remote_token);
// create/update federated user entry in DB
self.update_local_database(&auth_info, &urls).await.map_err(|e| super::AuthError {
message: format!("Failed to update DB entries for {}: {}", auth_info.display_name, e),
code: crate::data::error_codes::AuthErrorCode::Unknown,
})?;
// return local success token
let local_token = self.localify_token(remote_token)?;
Ok(super::UserLoginInfo {
response: libfj::robocraft::AuthenticationResponseInfo {
token: local_token,
refresh_token: refresh_token,
refresh_token_expiry: "0".to_string(), // TODO (seems like this isn't actually considered by the client)
},
is_new: false,
})
} else {
Err(super::AuthError {
message: format!("Refusing federation with {} for logging in {} (federation not configured)", auth_info.domain, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated,
})
}
}
fn localify_token(&self, remote_token: &str) -> Result<String, super::AuthError> {
let remote_token_data = jsonwebtoken::dangerous::insecure_decode::<crate::auth::Token>(remote_token)
.map_err(|e| super::AuthError {
message: e.to_string(),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
let local_token_data = crate::auth::Token {
iss: self.auth.to_string(),
fedi_token: Some(remote_token.to_owned()),
..remote_token_data.claims
};
let header = jsonwebtoken::Header {
typ: Some("JWT".to_string()),
alg: jsonwebtoken::Algorithm::HS256,
..Default::default()
};
let secret = jsonwebtoken::EncodingKey::from_secret(&self.secret);
let token = jsonwebtoken::encode(&header, &local_token_data, &secret)
.unwrap_or_else(|e| {
log::error!("Failed to encode fedi JWT: {}", e);
libfj::robocraft::DEFAULT_TOKEN.to_owned()
});
Ok(token)
}
async fn update_local_database(&self, auth_info: &super::FederatedAuthInfo, services_info: &oj_serdes::society::ServiceDomains) -> Result<(), oj_rc_database::sea_orm::DbErr> {
use oj_rc_database::sea_orm::IntoActiveModel;
let now = chrono::Utc::now().timestamp();
let fedi_id = if let Some(existing_fedi) = self.db.federation_by_domain(&services_info.root).await? {
let mut active = existing_fedi.into_active_model();
active.last_used_time = oj_rc_database::sea_orm::ActiveValue::Set(now);
active.auth = oj_rc_database::sea_orm::ActiveValue::Set(services_info.auth.clone());
active.cdn = oj_rc_database::sea_orm::ActiveValue::Set(services_info.cdn.clone());
active.factory = oj_rc_database::sea_orm::ActiveValue::Set(services_info.factory.clone());
active.society = oj_rc_database::sea_orm::ActiveValue::Set(services_info.society.clone());
self.db.update_federation(active).await?.id
} else {
let new_entity = oj_rc_database::schema::federation::ActiveModel {
id: oj_rc_database::sea_orm::ActiveValue::NotSet,
creation_time: oj_rc_database::sea_orm::ActiveValue::Set(now),
last_used_time: oj_rc_database::sea_orm::ActiveValue::Set(now),
domain: oj_rc_database::sea_orm::ActiveValue::Set(services_info.root.clone()),
auth: oj_rc_database::sea_orm::ActiveValue::Set(services_info.auth.clone()),
cdn: oj_rc_database::sea_orm::ActiveValue::Set(services_info.cdn.clone()),
factory: oj_rc_database::sea_orm::ActiveValue::Set(services_info.factory.clone()),
society: oj_rc_database::sea_orm::ActiveValue::Set(services_info.society.clone()),
};
self.db.insert_federation(new_entity).await?.id
};
let qualified_name = format!("{}#{}", auth_info.display_name, services_info.root);
if let Some(existing_user) = self.db.user_by_display_name_and_federation(qualified_name.clone(), fedi_id).await? {
log::info!("Using existing federated user with id {} for {} from {}", existing_user.id, auth_info.display_name, auth_info.domain);
} else {
let new_id = super::initial_data::register_new_federated_user(auth_info, fedi_id, &qualified_name, self.db.as_ref()).await?;
log::info!("Created federated user with id {} for {} from {}", new_id, auth_info.display_name, auth_info.domain);
}
Ok(())
}
async fn remote_auth_impl(&self, auth_info: &FederatedAuthenticationPayload, challenge: &str, federation: &Option<crate::persist::config::Federation>) -> Result<String, super::AuthError> {
if auth_info.display_name.is_empty() {
return Err(super::AuthError {
message: format!("Refusing federation login with empty username"),
code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName,
});
}
if let Some(fedi_conf) = federation {
if auth_info.domain_source.is_empty() {
return Err(super::AuthError {
message: format!("Refusing federation login with empty domain_source"),
code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName,
});
}
if auth_info.domain_target != *self.domain {
return Err(super::AuthError {
message: format!("Refusing federation login with not my domain_target"),
code: crate::data::error_codes::AuthErrorCode::InvalidDisplayName,
});
}
if self.is_defederated_from(&auth_info.domain_source, fedi_conf) {
return Err(super::AuthError {
message: format!("Refusing federation with {} for logging in {}", auth_info.domain_source, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated,
});
}
let user_info = super::UserAuthInfo::Username {
username: auth_info.display_name.clone(),
password: auth_info.password.clone(),
};
let login_info = self.login_internal(user_info, Some(auth_info.domain_source.clone())).await?;
Ok(Self::generate_access_code(
&*self.auth,
challenge,
&auth_info.display_name,
&login_info.response.token,
&login_info.response.refresh_token,
&*self.secret,
self.nonce_provider(),
))
} else {
Err(super::AuthError {
message: format!("Refusing federation with {} for logging in {} (federation not configured)", auth_info.domain_source, auth_info.display_name),
code: crate::data::error_codes::AuthErrorCode::PasswordInvalidated,
})
}
}
async fn remote_token_impl(&self, access_code: &str, verifier: &str) -> Result<super::UserLoginInfo, super::AuthError> {
let (_code, tokens) = Self::read_access_code(access_code, &self.auth, &self.secret, self.nonce_provider())
.map_err(|_| super::AuthError {
message: "Failed to read access code".to_owned(),
code: crate::data::error_codes::AuthErrorCode::BadCredentials,
})?;
if !Self::validate_pkce(&tokens.code_challenge, verifier) {
return Err(super::AuthError {
message: "Failed to validate PKCE".to_owned(),
code: crate::data::error_codes::AuthErrorCode::AccountUnconfirmed,
});
}
Ok(super::UserLoginInfo {
response: libfj::robocraft::AuthenticationResponseInfo {
token: tokens.access_token,
refresh_token: tokens.refresh_token,
refresh_token_expiry: "0".to_owned(), // TODO
},
is_new: false,
})
}
fn build_key(secret: &[u8], issuer: &str) -> Vec<u8> {
if secret.len() < 32 {
let mut temp_key = Vec::from(secret);
for b in issuer.bytes() {
temp_key.push(b);
if temp_key.len() >= 32 {
break;
}
}
if temp_key.len() < 32 {
for _ in temp_key.len()..32 {
temp_key.push(0);
}
}
temp_key
} else if secret.len() > 32 {
let mut temp_key = Vec::from(secret);
temp_key.truncate(32);
temp_key
} else {
Vec::from(secret)
}
}
fn generate_access_code(issuer: &str, challenge: &str, display_name: &str, access_token: &str, refresh_token: &str, secret: &[u8], mut noncer: NonceProvider) -> String {
use ring::aead::BoundKey;
use base64::Engine;
let now = chrono::Utc::now().timestamp();
noncer.reset(now);
let secure_data = SecuredCodes {
access_token: access_token.to_owned(),
refresh_token: refresh_token.to_owned(),
code_challenge: challenge.to_owned(),
};
let secure_data_str = serde_json::to_string(&secure_data).unwrap();
let key = Self::build_key(secret, issuer);
let enc_key = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &key).unwrap();
let mut seal_key = ring::aead::SealingKey::new(enc_key, noncer);
let mut enc_data = Vec::from(secure_data_str.as_bytes());
seal_key.seal_in_place_append_tag(ring::aead::Aad::from(ACCESS_CODE_AAD), &mut enc_data).unwrap();
let secured_b64 = base64::engine::general_purpose::STANDARD.encode(enc_data);
let token_data = AccessCode {
aud: issuer.to_owned(),
exp: now + 60, // 60s
iat: now,
iss: issuer.to_owned(),
sub: display_name.to_owned(),
secured: secured_b64,
};
let data_str = serde_json::to_string(&token_data).unwrap();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data_str)
}
fn read_access_code(code: &str, issuer: &str, secret: &[u8], mut noncer: NonceProvider) -> Result<(AccessCode, SecuredCodes), ()> {
use ring::aead::BoundKey;
use base64::Engine;
let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(code)
.map_err(|e| {
log::error!("Failed to decode Base64 access code: {}", e);
})?;
let access_code: AccessCode = serde_json::from_slice(&json_bytes)
.map_err(|e| {
log::error!("Failed to decode JSON access code: {}", e);
})?;
let mut enc_data = base64::engine::general_purpose::STANDARD.decode(&access_code.secured)
.map_err(|e| {
log::error!("Failed to decode Base64 secure code: {}", e);
})?;
noncer.reset(access_code.iat);
let key = Self::build_key(secret, issuer);
let enc_key = ring::aead::UnboundKey::new(&ring::aead::AES_256_GCM, &key).unwrap();
let mut opening_key = ring::aead::OpeningKey::new(enc_key, noncer);
let plaintext = opening_key.open_in_place(ring::aead::Aad::from(ACCESS_CODE_AAD), &mut enc_data)
.map_err(|e| {
log::error!("Failed to decrypt secure code: {}", e);
})?;
let secure_data: SecuredCodes = serde_json::from_slice(&plaintext)
.map_err(|e| {
log::error!("Failed to decode JSON secure code: {}", e);
})?;
Ok((access_code, secure_data))
}
fn validate_pkce(challenge: &str, verifier: &str) -> bool {
use sha2::Digest;
use base64::Engine;
let hash = sha2::Sha256::new()
.chain_update(verifier.as_bytes())
.finalize();
let expected = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash);
expected == challenge
}
}
#[async_trait::async_trait]
impl super::FederatedAuthenticator for super::AccountProvider {
async fn local_login(&self, info: super::FederatedAuthInfo) -> Result<super::UserLoginInfo, super::AuthError> {
self.local_login_impl(info, &self.federation).await
}
async fn remote_auth(&self, info: &FederatedAuthenticationPayload, challenge: &str) -> Result<String, super::AuthError> {
self.remote_auth_impl(info, challenge, &self.federation).await
}
async fn remote_token(&self, access_code: &str, verifier: &str) -> Result<super::UserLoginInfo, super::AuthError> {
self.remote_token_impl(access_code, verifier).await
}
}

View File

@@ -39,6 +39,14 @@ pub async fn register_new_user(info: &super::RegistrationInfo, db: &oj_rc_databa
Ok(user_data.id)
}
pub async fn register_new_federated_user(info: &super::FederatedAuthInfo, fedi_id: i32, qualified_name: &str, db: &oj_rc_database::Database) -> Result<i32, oj_rc_database::sea_orm::DbErr> {
let user_data = db.insert_user(default_fedi_user_data(info, fedi_id, qualified_name)).await?;
db.insert_perms(default_user_perms(user_data.id)).await?;
db.insert_user_aux(default_user_aux_data(user_data.id)).await?;
db.insert_garages(default_garage_slots(user_data.id)).await?;
Ok(user_data.id)
}
fn default_user_data(info: &super::RegistrationInfo) -> oj_rc_database::schema::user::ActiveModel {
let password = {
use argon2::password_hash::PasswordHasher;
@@ -61,6 +69,32 @@ fn default_user_data(info: &super::RegistrationInfo) -> oj_rc_database::schema::
password: oj_rc_database::sea_orm::ActiveValue::Set(password),
email: oj_rc_database::sea_orm::ActiveValue::Set(info.email.clone().unwrap_or_else(|| "".to_owned())),
steam_id: oj_rc_database::sea_orm::ActiveValue::Set(steam_id),
federation_id: oj_rc_database::sea_orm::ActiveValue::Set(None),
}
}
fn default_fedi_user_data(info: &super::FederatedAuthInfo, fedi_id: i32, qualified_name: &str) -> oj_rc_database::schema::user::ActiveModel {
let password = {
use argon2::password_hash::PasswordHasher;
let argon2_algo = argon2::Argon2::default();
let salt = argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
match argon2_algo.hash_password(info.password.as_bytes(), &salt) {
Err(e) => {
log::error!("Failed to hash password for user {}: {}", info.display_name, e);
"".to_owned()
},
Ok(password) => password.to_string(),
}
};
oj_rc_database::schema::user::ActiveModel {
id: Default::default(),
creation_time: oj_rc_database::sea_orm::ActiveValue::Set(current_unix_time()),
public_id: oj_rc_database::sea_orm::ActiveValue::Set(qualified_name.to_owned()),
display_name: oj_rc_database::sea_orm::ActiveValue::Set(qualified_name.to_owned()),
password: oj_rc_database::sea_orm::ActiveValue::Set(password),
email: oj_rc_database::sea_orm::ActiveValue::Set(format!("{}@{}", info.display_name, info.domain)),
steam_id: oj_rc_database::sea_orm::ActiveValue::Set(None),
federation_id: oj_rc_database::sea_orm::ActiveValue::Set(Some(fedi_id)),
}
}

View File

@@ -3,9 +3,10 @@ use serde::{Serialize, Deserialize};
impl super::account_json::UserData {
async fn listen_on_websocket<D: serde::de::DeserializeOwned>(&self, server_name: &str) -> Result<super::IntercomListener<D>, reqwest_websocket::Error> {
use reqwest_websocket::Upgrade;
let token = generate_token(format!("{}/{}", server_name, self.account.public_id).as_bytes(), &self.secret);
let url_encoded_pub_id = urlencoding::encode(&self.account.public_id);
let token = generate_token(format!("{}/{}", server_name, url_encoded_pub_id).as_bytes(), &self.secret);
let auth_header_val = format!("Internal {}", token);
let url = format!("{}/intercom/{}/{}", self.intercom, server_name, self.account.public_id);
let url = format!("{}/intercom/{}/{}", self.intercom, server_name, url_encoded_pub_id);
log::debug!("Listening on websocket {}", url);
let websocket = self.http_client.get(url)
.header("Authorization", auth_header_val)
@@ -21,7 +22,7 @@ impl super::account_json::UserData {
}
async fn post_to_intercom<D: serde::Serialize>(&self, data: &D, server_name: &str, operation: &str) -> Result<(), reqwest::Error> {
let path = format!("{}/{}/{}", server_name, self.account.public_id, operation);
let path = format!("{}/{}/{}", server_name, urlencoding::encode(&self.account.public_id), operation);
let token = generate_token(path.as_bytes(), &self.secret);
let auth_header_val = format!("Internal {}", token);
let url = format!("{}/intercom/{}", self.auth, path);
@@ -55,9 +56,10 @@ impl super::account_json::UserData {
impl super::IntercomUser for super::account_json::UserData {
async fn save_custom_avatar(&self, image: Vec<u8>) -> Result<(), polariton_server::operations::SimpleOpError> {
// seems to always be jpg
let token = generate_token(self.account.public_id.as_bytes(), &self.secret);
let url_encoded_pub_id = urlencoding::encode(&self.account.public_id);
let token = generate_token(url_encoded_pub_id.as_bytes(), &self.secret);
let auth_header_val = format!("Internal {}", token);
let url = format!("{}/customavatar/Live/{}", self.cdn, self.account.public_id);
let url = format!("{}/customavatar/Live/{}", self.cdn, url_encoded_pub_id);
if let Err(e) = self.http_client.post(url)
.header("Authorization", auth_header_val)
.body(image)

View File

@@ -11,7 +11,7 @@ mod inventory;
pub use inventory::{UnlockedParts, UnlockOverride};
mod traits;
pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, UserLoginInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser, GarageWebInfo, GarageWebStats, SanctionWebStats, AccountWebStats, SocialWebStats};
pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, FederatedAuthInfo, UserLoginInfo, UserAuthenticator, FederatedAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser, GarageWebInfo, GarageWebStats, SanctionWebStats, AccountWebStats, SocialWebStats};
pub mod intercom;
pub use intercom::generate_token as generate_intercom_token;
@@ -28,7 +28,7 @@ mod team;
pub use team::{TeamChooser, StandardTeamChooser};
mod web;
mod federation;
pub mod federation;
pub use federation::Federation;
pub const TOKEN_SECRET_FILENAME: &str = "token_secret.key";

View File

@@ -22,7 +22,7 @@ pub enum UserAuthInfo {
Email {
email: String,
password: String,
}
},
}
impl UserAuthInfo {
@@ -35,6 +35,12 @@ impl UserAuthInfo {
}
}
pub struct FederatedAuthInfo {
pub display_name: String,
pub password: String,
pub domain: String,
}
pub enum UserId {
SteamId(u64),
Email(String),
@@ -68,13 +74,20 @@ pub trait UserProvider<C> {
}
#[async_trait::async_trait]
pub trait UserAuthenticator {
pub trait UserAuthenticator: FederatedAuthenticator {
async fn login(&self, info: UserAuthInfo) -> Result<UserLoginInfo, AuthError>;
async fn user_exists(&self, user: UserId) -> Result<bool, String>;
async fn register(&self, info: RegistrationInfo) -> Result<i32, String>;
async fn verify(&self, token: String) -> Result<crate::auth::Token, AuthError>;
}
#[async_trait::async_trait]
pub trait FederatedAuthenticator {
async fn local_login(&self, info: FederatedAuthInfo) -> Result<UserLoginInfo, AuthError>;
async fn remote_auth(&self, info: &super::federation::FederatedAuthenticationPayload, challenge: &str) -> Result<String, AuthError>;
async fn remote_token(&self, access_token: &str, verifier: &str) -> Result<UserLoginInfo, AuthError>;
}
#[async_trait::async_trait]
pub trait User<C>: ChatUser + SocialUser + SocialUserC<C> + LobbyUser + MultiplayerUser + SingleplayerUser + IntercomUser + CommonUser + FactoryUser {
async fn unlocked_parts(&self) -> Vec<u32>;