diff --git a/README.md b/README.md index 5dc65a2..3784518 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,12 @@ these custom users are properly authenticated. - [user token generation](https://wiki.factorio.com/Web_authentication_API) and storage (`POST /api-login`) - LDAP authentication backend +- server padlock proxying (to allow e.g. factorio.com users to join servers using a custom auth + server) ### Planned - more authentication backends: user file, PAM(?) -- server padlock proxying (to allow e.g. factorio.com users to join servers using a custom auth - server) ### Unplanned @@ -42,9 +42,20 @@ these custom users are properly authenticated. ### Configuring factoriauth -Copy `config.toml.example` to `config.toml` and adjust as necessary. `padlock-secret` needs to be a -hex-encoded binary string of at least 32 bytes - either generate your own, or attempt to start -factoriauth once and copy the freshly generated secret from the error message. +Copy `config.toml.example` to `config.toml` and adjust as necessary. + +#### Padlock source + +There are two possible sources for server padlock: either generated standalone by factoriauth for +completely self-contained setups, or through a padlock proxy. + +For the standalone deployment, `padlock.secret` needs to be a hex-encoded binary string of at least +32 bytes - either generate your own, or attempt to start factoriauth once and copy the freshly +generated secret from the error message. + +To use the padlock proxy, `padlock.proxy` needs to be set to the base URL of another factorio auth +server, e.g. `https://auth.factorio.com`. Server padlocks will be obtained from this auth server, +allowing users from both factoriauth and the upstream auth server to join the game server. #### LDAP authentication backend notes diff --git a/config.toml.example b/config.toml.example index df589d3..41dc7f1 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,6 +1,9 @@ -padlock-secret = "" listen = "[::]:80" +[padlock] +secret = "" +#proxy = "https://some-other-auth-server.example" + [database] connection-string = "sqlite://sqlite.db" diff --git a/src/auth/mod.rs b/src/auth/mod.rs index f6cae38..2b2d385 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -8,14 +8,16 @@ use rand::{ thread_rng, }; use secrecy::ExposeSecret; +use serde::Deserialize; use sha2::Sha256; use thiserror::Error; use time::{macros::format_description, OffsetDateTime}; use tokio::sync::Mutex; use tracing::{event, instrument, Level}; +use url::Url; use crate::{ - config::AuthBackendConfig, + config::{AuthBackendConfig, PadlockConfig}, db::{Database, UserTokenEntry}, secrets::{ PadlockGenerationSecret, Password, ServerHash, ServerPadlock, UserServerKey, UserToken, @@ -38,6 +40,8 @@ pub enum AuthenticationError { Database(#[from] sqlx::Error), #[error("Authentication backend error")] Backend(#[from] ldap3::LdapError), + #[error("Padlock proxy error")] + PadlockProxy(#[from] PadlockProxyError), #[error("No authentication backends available")] NoBackends, } @@ -112,17 +116,18 @@ impl UserAuthenticator { }; let mut db = self.db.lock().await; - let token = - if let Some(UserTokenEntry::Valid(old_token, _, _)) = db.get_user_token(&username).await? { - old_token - } else { - let new_token = - UserToken::from(Alphanumeric.sample_string(&mut thread_rng(), Self::TOKEN_LEN)); + let token = if let Some(UserTokenEntry::Valid(old_token, _, _)) = + db.get_user_token(&username).await? + { + old_token + } else { + let new_token = + UserToken::from(Alphanumeric.sample_string(&mut thread_rng(), Self::TOKEN_LEN)); - db.save_user_token(&username, &new_token).await?; + db.save_user_token(&username, &new_token).await?; - new_token - }; + new_token + }; Ok((username, token)) } @@ -150,11 +155,11 @@ impl UserAuthenticator { } #[derive(Debug)] -pub struct ServerPadlockGenerator { +pub struct ServerPadlockSecret { secret: PadlockGenerationSecret, } -impl ServerPadlockGenerator { +impl ServerPadlockSecret { const HASH_LEN: usize = 32; pub fn new(secret: PadlockGenerationSecret) -> Self { @@ -178,6 +183,120 @@ impl ServerPadlockGenerator { } } +#[derive(Debug, Error)] +pub enum PadlockProxyError { + #[error("upstream responded with error")] + Upstream(#[from] reqwest::Error), + #[error("database error")] + Database(#[from] sqlx::Error), + #[error("invalid upstream URL")] + InvalidUrl(#[from] url::ParseError), + #[error("unknown server_hash")] + UnknownHash(ServerHash), +} + +#[derive(Debug)] +pub struct ServerPadlockProxy { + client: reqwest::Client, + upstream: Url, + database: Arc>>, +} + +impl ServerPadlockProxy { + pub fn new( + upstream: &Url, + database: Arc>>, + ) -> Result { + let client = reqwest::Client::new(); + let upstream = upstream.join("generate-server-padlock-2")?; + + Ok(Self { + client, + upstream, + database, + }) + } + + #[instrument] + pub async fn generate_hash(&self) -> Result { + #[derive(Deserialize)] + struct ServerPadlockResponse { + server_hash: ServerHash, + server_padlock: ServerPadlock, + } + + let response: ServerPadlockResponse = self + .client + .post(self.upstream.clone()) + .query(&[("api_version", 6)]) + .send() + .await? + .json() + .await?; + + self.database + .lock() + .await + .save_server_padlock(&response.server_hash, &response.server_padlock) + .await?; + + Ok(response.server_hash) + } + + #[instrument] + pub async fn generate_padlock( + &self, + server_hash: &ServerHash, + ) -> Result { + let Some(padlock) = self + .database + .lock() + .await + .get_server_padlock(server_hash) + .await? + else { + return Err(PadlockProxyError::UnknownHash(server_hash.clone())); + }; + + Ok(padlock) + } +} + +#[derive(Debug)] +pub enum ServerPadlockGenerator { + Secret(ServerPadlockSecret), + Proxy(ServerPadlockProxy), +} + +impl ServerPadlockGenerator { + pub fn new( + config: PadlockConfig, + db: Arc>>, + ) -> Result { + match config { + PadlockConfig::Secret(s) => Ok(Self::Secret(ServerPadlockSecret::new(s))), + PadlockConfig::Proxy(u) => ServerPadlockProxy::new(&u, db).map(Self::Proxy), + } + } + + pub async fn generate_hash(&self) -> Result { + match self { + ServerPadlockGenerator::Secret(_) => Ok(ServerPadlockSecret::generate_hash()), + ServerPadlockGenerator::Proxy(p) => p.generate_hash().await, + } + } + + pub async fn generate_padlock( + &self, + server_hash: &ServerHash, + ) -> Result { + match self { + ServerPadlockGenerator::Secret(s) => Ok(s.generate_padlock(server_hash)), + ServerPadlockGenerator::Proxy(p) => p.generate_padlock(server_hash).await, + } + } +} + #[derive(Debug)] pub struct UserServerKeyGenerator { user_authenticator: Arc, @@ -206,7 +325,7 @@ impl UserServerKeyGenerator { .verify_user_token(username, token) .await?; - let padlock = self.padlock_generator.generate_padlock(server_hash); + let padlock = self.padlock_generator.generate_padlock(server_hash).await?; #[allow(clippy::expect_used)] let timestamp = OffsetDateTime::now_utc() diff --git a/src/config.rs b/src/config.rs index 2ec75d6..9d99a56 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6}; use serde::Deserialize; +use url::Url; use crate::secrets::PadlockGenerationSecret; @@ -11,16 +12,23 @@ fn default_listen_addr() -> SocketAddr { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct Config { - #[serde(with = "hex::serde")] - pub padlock_secret: PadlockGenerationSecret, #[serde(default = "default_listen_addr")] pub listen: SocketAddr, + pub padlock: PadlockConfig, pub database: DatabaseConfig, #[serde(default)] pub auth_backends: Vec, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[allow(clippy::module_name_repetitions)] +pub enum PadlockConfig { + Secret(#[serde(with = "hex::serde")] PadlockGenerationSecret), + Proxy(Url), +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case")] #[allow(clippy::module_name_repetitions)] diff --git a/src/db.rs b/src/db.rs index 502a2f9..b49810e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,7 +5,7 @@ use secrecy::ExposeSecret; use sqlx::{query, query_as, sqlite::SqliteConnectOptions, Connection, SqliteConnection}; use tracing::instrument; -use crate::secrets::UserToken; +use crate::secrets::{ServerHash, ServerPadlock, UserToken}; // TODO: check again if it's possible to pass this as a parameter to query! // const TABLE_USER_TOKENS: &str = "user_tokens"; @@ -41,6 +41,17 @@ pub trait Database: Debug { username: &str, token: &UserToken, ) -> Result<(), sqlx::Error>; + + async fn get_server_padlock( + &mut self, + server_hash: &ServerHash, + ) -> Result, sqlx::Error>; + + async fn save_server_padlock( + &mut self, + server_hash: &ServerHash, + server_padlock: &ServerPadlock, + ) -> Result<(), sqlx::Error>; } #[derive(Debug)] @@ -77,6 +88,15 @@ impl SqliteDatabase { .execute(&mut self.conn) .await?; + query!( + "CREATE TABLE IF NOT EXISTS server_padlocks ( + hash TEXT NOT NULL UNIQUE, + padlock TEXT NOT NULL + ) STRICT" + ) + .execute(&mut self.conn) + .await?; + Ok(()) } } @@ -160,4 +180,41 @@ impl Database for SqliteDatabase { Ok(()) } + + #[instrument] + async fn get_server_padlock( + &mut self, + server_hash: &ServerHash, + ) -> Result, sqlx::Error> { + let server_hash = &server_hash.0; + + let padlock = query!( + "SELECT padlock FROM server_padlocks WHERE hash = $1", + server_hash + ) + .fetch_optional(&mut self.conn) + .await?; + + Ok(padlock.map(|d| ServerPadlock::from(d.padlock))) + } + + #[instrument] + async fn save_server_padlock( + &mut self, + server_hash: &ServerHash, + server_padlock: &ServerPadlock, + ) -> Result<(), sqlx::Error> { + let server_hash = &server_hash.0; + let server_padlock = server_padlock.0.expose_secret(); + + query!( + "INSERT INTO server_padlocks (hash, padlock) VALUES ($1, $2)", + server_hash, + server_padlock + ) + .execute(&mut self.conn) + .await?; + + Ok(()) + } } diff --git a/src/main.rs b/src/main.rs index 11e81e9..9241dda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,8 +129,8 @@ async fn main() -> Result<()> { ); } - let user_authenticator = Arc::new(UserAuthenticator::new(database, auth_backends)); - let padlock_generator = Arc::new(ServerPadlockGenerator::new(config.padlock_secret)); + let user_authenticator = Arc::new(UserAuthenticator::new(Arc::clone(&database), auth_backends)); + let padlock_generator = Arc::new(ServerPadlockGenerator::new(config.padlock, database)?); let user_server_key_generator = Arc::new(UserServerKeyGenerator::new( Arc::clone(&user_authenticator), Arc::clone(&padlock_generator), diff --git a/src/server.rs b/src/server.rs index 8f70570..feef926 100644 --- a/src/server.rs +++ b/src/server.rs @@ -180,8 +180,14 @@ async fn generate_server_padlock_2( ) -> ApiResult> { event!(Level::INFO, "Creating server padlock"); - let server_hash = ServerPadlockGenerator::generate_hash(); - let server_padlock = server_padlock_generator.generate_padlock(&server_hash); + let server_hash = server_padlock_generator + .generate_hash() + .await + .map_err(AuthenticationError::from)?; + let server_padlock = server_padlock_generator + .generate_padlock(&server_hash) + .await + .map_err(AuthenticationError::from)?; Ok(Json(ServerPadlockResponse { server_hash,