diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1b80e5e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,75 @@ +name: Build + +on: + push: + branches-ignore: + - gh-readonly-queue/** + pull_request: + merge_group: + +jobs: + build-cmake: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Prepare Linux + if: contains(matrix.os, 'ubuntu') + run: | + sudo apt-get update -y + sudo apt-get install rustc cargo postfix mailutils -y + + - name: Prepare Linux (mysql & email) + if: ${{ contains(matrix.os, 'ubuntu') && !matrix.fancy }} + run: | + sudo touch /etc/aliases + sudo bash -c 'echo "test: root" >> /etc/aliases' + sudo bash -c 'echo "test2: root" >> /etc/aliases' + sudo postalias /etc/aliases + sudo apt-get install libmariadb-dev mariadb-server -y + sudo mysql <> /etc/aliases' +sudo bash -c 'echo "test2: root" >> /etc/aliases' +sudo postalias /etc/aliases +``` + +To enhance security the database must support TLS connections (for non localhost): +- For MySQL in `/etc/mysql/my.cnf` (or `.conf`) add + ```cfg + [mysqld] + ssl_ca = /etc/mysql/ssl/ca-cert.pem + ssl_cert = /etc/mysql/ssl/server-cert.pem + ssl_key = /etc/mysql/ssl/server-key.pem + ``` + + after creating the required keys: + ```bash + sudo mkdir -p /etc/mysql/ssl + # generate ca key & cert + sudo bash -c "openssl genrsa 2048 > /etc/mysql/ssl/ca-key.pem" + sudo bash -c "openssl req -sha256 -new -x509 -nodes -key /etc/mysql/ssl/ca-key.pem -subj \"/CN=localhost\" -days 36500 > /etc/mysql/ssl/ca-cert.pem" + # generate server key & csr + sudo bash -c "openssl req -sha256 -newkey rsa:2048 -nodes -keyout /etc/mysql/ssl/server-key.pem -subj \"/CN=localhost\" -addext \"subjectAltName = DNS:localhost,DNS:localhost\" -addext \"basicConstraints = CA:FALSE\" -addext \"keyUsage = digitalSignature, keyEncipherment\" -addext \"extendedKeyUsage = serverAuth, clientAuth\" > /etc/mysql/ssl/server-req.pem" + sudo bash -c "openssl rsa -in /etc/mysql/ssl/server-key.pem -out /etc/mysql/ssl/server-key.pem" + # generate server cert + sudo bash -c "openssl x509 -days 36500 -sha256 -req -copy_extensions=copyall -in /etc/mysql/ssl/server-req.pem -CA /etc/mysql/ssl/ca-cert.pem -CAkey /etc/mysql/ssl/ca-key.pem -set_serial 01 > /etc/mysql/ssl/server-cert.pem" + sudo chown -R mysql:mysql /etc/mysql/ssl + # reading certs is allowed for everyone + sudo chmod -R 666 /etc/mysql/ssl + sudo chmod 777 /etc/mysql/ssl + # but the server key and ca key stay secret + sudo chmod 600 /etc/mysql/ssl/server-key.pem + sudo chmod 600 /etc/mysql/ssl/ca-key.pem + ``` + +SQL formatting is doing with `sleek -n `. + +Allow & deny lists for ip bans, mail domain bans & allow lists are automatically reloaded on change. +It's strongly recommended to create the files somewhere else and only use `mv` to overwrite the files, since +file system operations are rather racy, which in worst case can lead to loading a partially written file. +(The watcher tries to minimize these events by only listening for write-close events and similar, but can't prevent +all cases.) + + +After executing `./account-server --setup` there should be a `config` directory. +Inside that directory the ban & allow lists are placed. Additionally the email templates +are rooted there. +To use the existing email template do: +``` +cp templates/email/template.html config/account_tokens.html +cp templates/email/template.html config/credential_auth_tokens.html +``` + +Tests must be executed with: +``` +cargo test -p account-server -- --test-threads=1 +``` + +Since all tests cleanup the database after being done and otherwise this operation would be racy. diff --git a/lib/account-client/Cargo.toml b/lib/account-client/Cargo.toml new file mode 100644 index 0000000..c76dcd1 --- /dev/null +++ b/lib/account-client/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "account-client" +version = "0.1.0" +edition = "2021" +authors = ["Jupeyy"] +license = "MIT OR Apache-2.0" +description = "The account related operations of a client, that want to manage accounts & do requests to the account server." + +[dependencies] +accounts-shared = { version = "0.1.0", path = "../accounts-shared" } +accounts-types = { version = "0.1.0", path = "../accounts-types" } + +async-trait = "0.1.81" +email_address = { version = "0.2.9", features = ["serde"] } +serde_json = "1.0.125" +anyhow = { version = "1.0.86", features = ["backtrace"] } +thiserror = "1.0.63" +serde = { version = "1.0.208", features = ["derive"] } +x509-parser = { version = "0.16.0", default-features = false } +x509-cert = { version = "0.2.5" } +hex = "0.4.3" + +[dev-dependencies] +pollster = "0.3.0" diff --git a/lib/account-client/src/account_info.rs b/lib/account-client/src/account_info.rs new file mode 100644 index 0000000..fc06754 --- /dev/null +++ b/lib/account-client/src/account_info.rs @@ -0,0 +1,66 @@ +use accounts_shared::{ + account_server::account_info::AccountInfoResponse, + client::{account_info::prepare_account_info_request, machine_id::machine_uid}, +}; +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`account_info`] request. +#[derive(Error, Debug)] +pub enum AccountInfoResult { + /// Session was invalid, must login again. + #[error("The session was not valid anymore.")] + SessionWasInvalid, + /// A file system like error occurred. + /// This usually means the user was not yet logged in. + #[error("{0}")] + FsLikeError(FsLikeError), + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// Errors that are not handled explicitly. + #[error("Fetching account info failed: {0}")] + Other(anyhow::Error), +} + +impl From for AccountInfoResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for AccountInfoResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// Fetches the account info of an account for an existing session on the account server. +/// +/// # Errors +/// +/// If an error occurs this usually means that the session is not valid anymore. +pub async fn account_info(io: &dyn Io) -> anyhow::Result { + account_info_impl(io.into()).await +} + +async fn account_info_impl( + io: IoSafe<'_>, +) -> anyhow::Result { + // read session's key-pair + let key_pair = io.read_serialized_session_key_pair().await?; + + let hashed_hw_id = machine_uid().map_err(AccountInfoResult::Other)?; + + // do the account info request using the above private key + let msg = + prepare_account_info_request(hashed_hw_id, &key_pair.private_key, key_pair.public_key); + io.request_account_info(msg) + .await? + .map_err(|err| AccountInfoResult::Other(err.into())) +} diff --git a/lib/account-client/src/account_token.rs b/lib/account-client/src/account_token.rs new file mode 100644 index 0000000..8587b73 --- /dev/null +++ b/lib/account-client/src/account_token.rs @@ -0,0 +1,133 @@ +use accounts_shared::{ + account_server::{account_token::AccountTokenError, errors::AccountServerRequestError}, + client::account_token::{ + AccountTokenEmailRequest, AccountTokenOperation, AccountTokenSteamRequest, SecretKey, + }, +}; + +use anyhow::anyhow; +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`account`] request. +#[derive(Error, Debug)] +pub enum AccountTokenResult { + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// A fs like error occurred. + #[error("{0}")] + FsLikeError(FsLikeError), + #[error("{0:?}")] + /// The account server responded with an error. + AccountServerRequstError(AccountServerRequestError), + /// Errors that are not handled explicitly. + #[error("Account failed: {0}")] + Other(anyhow::Error), +} + +impl From for AccountTokenResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for AccountTokenResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +fn get_secret_key( + secret_key_hex: Option, +) -> anyhow::Result, AccountTokenResult> { + secret_key_hex + .map(hex::decode) + .transpose() + .map_err(|err| AccountTokenResult::Other(err.into()))? + .map(|secret_key| secret_key.try_into()) + .transpose() + .map_err(|_| { + AccountTokenResult::Other(anyhow!( + "secret key had an invalid length. make sure you copied it correctly." + )) + }) +} + +/// Generate a token sent by email. +pub async fn account_token_email( + email: email_address::EmailAddress, + op: AccountTokenOperation, + secret_key_hex: Option, + io: &dyn Io, +) -> anyhow::Result<(), AccountTokenResult> { + account_token_email_impl(email, op, secret_key_hex, io.into()).await +} + +async fn account_token_email_impl( + email: email_address::EmailAddress, + op: AccountTokenOperation, + secret_key_hex: Option, + io: IoSafe<'_>, +) -> anyhow::Result<(), AccountTokenResult> { + if secret_key_hex.is_some() { + io.request_account_token_email_secret(AccountTokenEmailRequest { + email, + secret_key: get_secret_key(secret_key_hex)?, + op, + }) + .await + } else { + io.request_account_token_email(AccountTokenEmailRequest { + email, + secret_key: get_secret_key(secret_key_hex)?, + op, + }) + .await + }? + .map_err(AccountTokenResult::AccountServerRequstError)?; + + Ok(()) +} + +/// Request an account token for the given steam credential. +/// The token is serialized in hex. +pub async fn account_token_steam( + steam_ticket: Vec, + op: AccountTokenOperation, + secret_key_hex: Option, + io: &dyn Io, +) -> anyhow::Result { + account_token_steam_impl(steam_ticket, op, secret_key_hex, io.into()).await +} + +async fn account_token_steam_impl( + steam_ticket: Vec, + op: AccountTokenOperation, + secret_key_hex: Option, + io: IoSafe<'_>, +) -> anyhow::Result { + let account_token_hex = if secret_key_hex.is_some() { + io.request_account_token_steam_secret(AccountTokenSteamRequest { + steam_ticket, + secret_key: get_secret_key(secret_key_hex)?, + op, + }) + .await + } else { + io.request_account_token_steam(AccountTokenSteamRequest { + steam_ticket, + secret_key: get_secret_key(secret_key_hex)?, + op, + }) + .await + }? + .map_err(AccountTokenResult::AccountServerRequstError)?; + + Ok(account_token_hex) +} diff --git a/lib/account-client/src/certs.rs b/lib/account-client/src/certs.rs new file mode 100644 index 0000000..e5ef279 --- /dev/null +++ b/lib/account-client/src/certs.rs @@ -0,0 +1,48 @@ +use accounts_shared::{ + account_server::certs::AccountServerCertificates, game_server::user_id::VerifyingKey, +}; +use anyhow::anyhow; +use x509_cert::{ + der::{Decode, Encode}, + spki::DecodePublicKey, +}; + +use crate::{ + errors::HttpLikeError, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// Downloads the latest legit account server certificates that are used to verify client +/// certificates signed by the account server +pub async fn download_certs( + io: &dyn Io, +) -> anyhow::Result { + download_certs_impl(io.into()).await +} + +async fn download_certs_impl( + io: IoSafe<'_>, +) -> anyhow::Result { + let certs = io.download_account_server_certificates().await?; + certs + .map_err(|err| HttpLikeError::Other(err.into()))? + .into_iter() + .map(|cert| x509_cert::Certificate::from_der(&cert).map_err(|err| anyhow!(err))) + .collect::>>() + .map_err(HttpLikeError::Other) +} + +/// Extract the public key from certificates +pub fn certs_to_pub_keys(certs: &[x509_cert::Certificate]) -> Vec { + certs + .iter() + .flat_map(|cert| { + cert.tbs_certificate + .subject_public_key_info + .to_der() + .ok() + .and_then(|v| VerifyingKey::from_public_key_der(&v).ok()) + }) + .collect::>() +} diff --git a/lib/account-client/src/credential_auth_token.rs b/lib/account-client/src/credential_auth_token.rs new file mode 100644 index 0000000..603ff4c --- /dev/null +++ b/lib/account-client/src/credential_auth_token.rs @@ -0,0 +1,140 @@ +use accounts_shared::{ + account_server::{ + credential_auth_token::CredentialAuthTokenError, errors::AccountServerRequestError, + }, + client::credential_auth_token::{ + CredentialAuthTokenEmailRequest, CredentialAuthTokenOperation, + CredentialAuthTokenSteamRequest, SecretKey, + }, +}; + +use anyhow::anyhow; +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`credential_auth_token_email`] request. +#[derive(Error, Debug)] +pub enum CredentialAuthTokenResult { + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// A fs like error occurred. + #[error("{0}")] + FsLikeError(FsLikeError), + /// The account server responded with an error. + #[error("{0:?}")] + AccountServerRequstError(AccountServerRequestError), + /// Errors that are not handled explicitly. + #[error("Credential authorization failed: {0}")] + Other(anyhow::Error), +} + +impl From for CredentialAuthTokenResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for CredentialAuthTokenResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +fn get_secret_key( + secret_key_hex: Option, +) -> anyhow::Result, CredentialAuthTokenResult> { + secret_key_hex + .map(hex::decode) + .transpose() + .map_err(|err| CredentialAuthTokenResult::Other(err.into()))? + .map(|secret_key| secret_key.try_into()) + .transpose() + .map_err(|_| { + CredentialAuthTokenResult::Other(anyhow!( + "secret key had an invalid length. make sure you copied it correctly." + )) + }) +} + +/// Generate a token sent by email for a new session/account. +pub async fn credential_auth_token_email( + email: email_address::EmailAddress, + op: CredentialAuthTokenOperation, + secret_key_hex: Option, + io: &dyn Io, +) -> anyhow::Result<(), CredentialAuthTokenResult> { + credential_auth_token_email_impl(email, op, secret_key_hex, io.into()).await +} + +async fn credential_auth_token_email_impl( + email: email_address::EmailAddress, + op: CredentialAuthTokenOperation, + secret_key_hex: Option, + io: IoSafe<'_>, +) -> anyhow::Result<(), CredentialAuthTokenResult> { + let secret_key = get_secret_key(secret_key_hex)?; + if secret_key.is_some() { + io.request_credential_auth_email_token_with_secret_key(CredentialAuthTokenEmailRequest { + email, + secret_key, + op, + }) + .await? + .map_err(CredentialAuthTokenResult::AccountServerRequstError)?; + } else { + io.request_credential_auth_email_token(CredentialAuthTokenEmailRequest { + email, + secret_key, + op, + }) + .await? + .map_err(CredentialAuthTokenResult::AccountServerRequstError)?; + } + + Ok(()) +} + +/// Generate a token sent for a steam auth for a new session/account. +/// On success the credential auth token is returned in hex format. +pub async fn credential_auth_token_steam( + steam_ticket: Vec, + op: CredentialAuthTokenOperation, + secret_key_hex: Option, + io: &dyn Io, +) -> anyhow::Result { + credential_auth_token_steam_impl(steam_ticket, op, secret_key_hex, io.into()).await +} + +async fn credential_auth_token_steam_impl( + steam_ticket: Vec, + op: CredentialAuthTokenOperation, + secret_key_hex: Option, + io: IoSafe<'_>, +) -> anyhow::Result { + let secret_key = get_secret_key(secret_key_hex)?; + let credential_auth_token_hex = if secret_key.is_some() { + io.request_credential_auth_steam_token_with_secret_key(CredentialAuthTokenSteamRequest { + steam_ticket, + secret_key, + op, + }) + .await? + .map_err(CredentialAuthTokenResult::AccountServerRequstError)? + } else { + io.request_credential_auth_steam_token(CredentialAuthTokenSteamRequest { + steam_ticket, + secret_key, + op, + }) + .await? + .map_err(CredentialAuthTokenResult::AccountServerRequstError)? + }; + + Ok(credential_auth_token_hex) +} diff --git a/lib/account-client/src/delete.rs b/lib/account-client/src/delete.rs new file mode 100644 index 0000000..2fbcb49 --- /dev/null +++ b/lib/account-client/src/delete.rs @@ -0,0 +1,54 @@ +use accounts_shared::client::delete; +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`delete`] request. +#[derive(Error, Debug)] +pub enum DeleteResult { + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// A fs like error occurred. + #[error("{0}")] + FsLikeError(FsLikeError), + /// Errors that are not handled explicitly. + #[error("Delete failed: {0}")] + Other(anyhow::Error), +} + +impl From for DeleteResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for DeleteResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// Delete an account on the account server. +pub async fn delete(account_token_hex: String, io: &dyn Io) -> anyhow::Result<(), DeleteResult> { + delete_impl(account_token_hex, io.into()).await +} + +async fn delete_impl( + account_token_hex: String, + io: IoSafe<'_>, +) -> anyhow::Result<(), DeleteResult> { + let delete_req = delete::delete(account_token_hex).map_err(DeleteResult::Other)?; + + io.request_delete_account(delete_req) + .await? + .map_err(|err| DeleteResult::Other(err.into()))?; + // this is generally allowed to fail + let _ = io.remove_serialized_session_key_pair().await; + + Ok(()) +} diff --git a/lib/account-client/src/errors.rs b/lib/account-client/src/errors.rs new file mode 100644 index 0000000..070ddf3 --- /dev/null +++ b/lib/account-client/src/errors.rs @@ -0,0 +1,42 @@ +use thiserror::Error; + +/// An error that is similar to +/// common http errrors. +/// Used for requests to the account +/// server. +#[derive(Error, Debug)] +pub enum HttpLikeError { + /// The request failed. + #[error("The request failed to be sent.")] + Request, + /// Http-like status codes. + #[error("The server responsed with status code {0}")] + Status(u16), + /// Other errors + #[error("{0}")] + Other(anyhow::Error), +} + +impl From for HttpLikeError { + fn from(value: serde_json::Error) -> Self { + Self::Other(value.into()) + } +} + +/// An error that is similar to +/// a file system error. +#[derive(Error, Debug)] +pub enum FsLikeError { + /// The request failed. + #[error("{0}")] + Fs(std::io::Error), + /// Other errors + #[error("{0}")] + Other(anyhow::Error), +} + +impl From for FsLikeError { + fn from(value: std::io::Error) -> Self { + Self::Fs(value) + } +} diff --git a/lib/account-client/src/interface.rs b/lib/account-client/src/interface.rs new file mode 100644 index 0000000..cc21ce4 --- /dev/null +++ b/lib/account-client/src/interface.rs @@ -0,0 +1,115 @@ +use async_trait::async_trait; + +use crate::errors::{FsLikeError, HttpLikeError}; + +/// An io interface for the client to abstract away +/// the _actual_ communication used to communicate +/// with the account server. +#[async_trait] +pub trait Io: Sync + Send { + /// Requests an one time password from the account server for the given email. + /// Sends & receives it as arbitrary data. + async fn request_credential_auth_email_token( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests an one time password from the account server for the given steam token. + /// Sends & receives it as arbitrary data. + async fn request_credential_auth_steam_token( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests an one time password from the account server for the given email. + /// It additionally includes a secret key that authorizes this connection + /// for verification processes like captchas. + /// Sends & receives it as arbitrary data. + async fn request_credential_auth_email_token_with_secret_key( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests an one time password from the account server for the given steam token. + /// It additionally includes a secret key that authorizes this connection + /// for verification processes like captchas. + /// Sends & receives it as arbitrary data. + async fn request_credential_auth_steam_token_with_secret_key( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests a login for the given account. + /// Sends & receives it as arbitrary data. + async fn request_login(&self, data: Vec) -> anyhow::Result, HttpLikeError>; + /// Requests a logout for the given session. + /// Sends & receives it as arbitrary data. + async fn request_logout(&self, data: Vec) -> anyhow::Result, HttpLikeError>; + /// Requests the account server to sign a certificate. + /// Sends & receives it as arbitrary data. + async fn request_sign(&self, data: Vec) -> anyhow::Result, HttpLikeError>; + /// Requests an one time password (account token) from the account server for the given email. + /// Sends & receives it as arbitrary data. + async fn request_account_token_email( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests an one time password (account token) from the account server for the given email + /// and secret key. + /// Sends & receives it as arbitrary data. + async fn request_account_token_email_secret( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests an one time password (account token) + /// from the account server for the given steam credential and secret key. + /// Returns a serialized account token. + /// Sends & receives it as arbitrary data. + async fn request_account_token_steam( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests an one time password (account token) + /// from the account server for the given steam credential. + /// Returns a serialized account token. + /// Sends & receives it as arbitrary data. + async fn request_account_token_steam_secret( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests to delete all session for the given account. + /// Sends & receives it as arbitrary data. + async fn request_logout_all(&self, data: Vec) -> anyhow::Result, HttpLikeError>; + /// Requests to delete an account. + /// Sends & receives it as arbitrary data. + async fn request_delete_account(&self, data: Vec) + -> anyhow::Result, HttpLikeError>; + /// Requests to link a credential for an account. + /// Sends & receives it as arbitrary data. + async fn request_link_credential( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests to unlink a credential from an account. + /// Sends & receives it as arbitrary data. + async fn request_unlink_credential( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError>; + /// Requests the account info of the account. + /// Sends & receives it as arbitrary data. + async fn request_account_info(&self, data: Vec) -> anyhow::Result, HttpLikeError>; + /// Downloads the latest certificates of the account server. + /// Sends & receives it as arbitrary data. + async fn download_account_server_certificates(&self) -> anyhow::Result, HttpLikeError>; + /// Write the serialized session key pair to a secure storage + /// (at least obviously named like `password_data`) + /// on the client. + /// Note: the file is not compressed, just serialized. + async fn write_serialized_session_key_pair( + &self, + file: Vec, + ) -> anyhow::Result<(), FsLikeError>; + /// Read the serialized session key pair from storage + /// on the client, previously written by [`Io::write_serialized_session_key_pair`]. + /// Note: the file must not be compressed, just serialized. + async fn read_serialized_session_key_pair(&self) -> anyhow::Result, FsLikeError>; + /// Remove the account data from file disk + async fn remove_serialized_session_key_pair(&self) -> anyhow::Result<(), FsLikeError>; +} diff --git a/lib/account-client/src/lib.rs b/lib/account-client/src/lib.rs new file mode 100644 index 0000000..8c7496b --- /dev/null +++ b/lib/account-client/src/lib.rs @@ -0,0 +1,45 @@ +//! This crate contains a base implementation for +//! a client to do account related operations. +//! It helps sending data, storing results persistently. +//! This crate is not intended for creating UI, +//! any game logic nor knowing about the communication details +//! (be it UDP, HTTP or other stuff). +//! It uses interfaces to abstract such concepts away. + +#![deny(missing_docs)] +#![deny(warnings)] +#![deny(clippy::nursery)] +#![deny(clippy::all)] + +pub(crate) mod safe_interface; + +/// Requests the account info of the account. +pub mod account_info; +/// Requests an account token email based. +pub mod account_token; +/// Operations related to getting the account server certificates +pub mod certs; +/// Requests a token for an email based login. +pub mod credential_auth_token; +/// Requests to delete the account. +pub mod delete; +/// Types related to errors during client operations. +pub mod errors; +/// Communication interface for the client to +/// do requests to the account server. +pub mod interface; +/// Requests to link another credential to an +/// existing account. +pub mod link_credential; +/// Requests to create a new login for the corresponding +/// account. +pub mod login; +/// Request to log out the current user session. +pub mod logout; +/// Request to log out all sessions of a user. +pub mod logout_all; +/// Sign an already existing session key-pair +/// with a certificate on the account server. +pub mod sign; +/// Requests to unlink a credential from an account. +pub mod unlink_credential; diff --git a/lib/account-client/src/link_credential.rs b/lib/account-client/src/link_credential.rs new file mode 100644 index 0000000..a8f82c8 --- /dev/null +++ b/lib/account-client/src/link_credential.rs @@ -0,0 +1,65 @@ +use accounts_shared::{ + account_server::errors::{AccountServerRequestError, Empty}, + client::link_credential::{self}, +}; + +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`link_credential`] request. +#[derive(Error, Debug)] +pub enum LinkCredentialResult { + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// A fs like error occurred. + #[error("{0}")] + FsLikeError(FsLikeError), + /// The account server responded with an error. + #[error("{0:?}")] + AccountServerRequstError(AccountServerRequestError), + /// Errors that are not handled explicitly. + #[error("Linking credential failed: {0}")] + Other(anyhow::Error), +} + +impl From for LinkCredentialResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for LinkCredentialResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// Link another crendential to an account. +pub async fn link_credential( + account_token_hex: String, + credential_auth_token_hex: String, + io: &dyn Io, +) -> anyhow::Result<(), LinkCredentialResult> { + link_credential_impl(account_token_hex, credential_auth_token_hex, io.into()).await +} + +async fn link_credential_impl( + account_token_hex: String, + credential_auth_token_hex: String, + io: IoSafe<'_>, +) -> anyhow::Result<(), LinkCredentialResult> { + io.request_link_credential( + link_credential::link_credential(account_token_hex, credential_auth_token_hex) + .map_err(LinkCredentialResult::Other)?, + ) + .await? + .map_err(LinkCredentialResult::AccountServerRequstError)?; + + Ok(()) +} diff --git a/lib/account-client/src/login.rs b/lib/account-client/src/login.rs new file mode 100644 index 0000000..f642f8b --- /dev/null +++ b/lib/account-client/src/login.rs @@ -0,0 +1,121 @@ +use accounts_shared::{ + account_server::{errors::AccountServerRequestError, login::LoginError}, + client::{ + account_data::AccountDataForClient, + login::{self, LoginRequest}, + }, +}; +use accounts_types::account_id::AccountId; +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`login`] request. +#[derive(Error, Debug)] +pub enum LoginResult { + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// A fs like error occurred. + #[error("{0}")] + FsLikeError(FsLikeError), + /// The account server responded with an error. + #[error("{0}")] + AccountServerRequstError(AccountServerRequestError), + /// Errors that are not handled explicitly. + #[error("Login failed: {0}")] + Other(anyhow::Error), +} + +impl From for LoginResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for LoginResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// Writes the session data to disk +#[must_use = "This writes the login data and must be used by calling \"write\" of this object"] +#[derive(Debug)] +pub struct LoginWriter { + account_data: AccountDataForClient, +} + +impl LoginWriter { + /// Writes the session data to disk + async fn write_impl(self, io: IoSafe<'_>) -> anyhow::Result<(), FsLikeError> { + io.write_serialized_session_key_pair(&self.account_data) + .await + } + + /// Writes the session data to disk + pub async fn write(self, io: &dyn Io) -> anyhow::Result<(), FsLikeError> { + self.write_impl(io.into()).await + } +} + +async fn login_inner_impl( + login_req: LoginRequest, + login_data: AccountDataForClient, + io: IoSafe<'_>, +) -> anyhow::Result<(AccountId, LoginWriter), LoginResult> { + let account_id = io + .request_login(login_req) + .await? + .map_err(LoginResult::AccountServerRequstError)?; + + Ok(( + account_id, + LoginWriter { + account_data: login_data, + }, + )) +} + +/// Create a new session (or account if not existed) on the account server. +pub async fn login_with_account_data( + credential_auth_token_hex: String, + account_data: &AccountDataForClient, + io: &dyn Io, +) -> anyhow::Result<(AccountId, LoginWriter), LoginResult> { + login_with_account_data_impl(credential_auth_token_hex, account_data, io.into()).await +} + +async fn login_with_account_data_impl( + credential_auth_token_hex: String, + account_data: &AccountDataForClient, + io: IoSafe<'_>, +) -> anyhow::Result<(AccountId, LoginWriter), LoginResult> { + let (login_req, login_data) = + login::login_from_client_account_data(account_data, credential_auth_token_hex) + .map_err(LoginResult::Other)?; + + login_inner_impl(login_req, login_data, io).await +} + +/// Create a new session (or account if not existed) on the account server. +pub async fn login( + credential_auth_token_hex: String, + io: &dyn Io, +) -> anyhow::Result<(AccountId, LoginWriter), LoginResult> { + login_impl(credential_auth_token_hex, io.into()).await +} + +async fn login_impl( + credential_auth_token_hex: String, + io: IoSafe<'_>, +) -> anyhow::Result<(AccountId, LoginWriter), LoginResult> { + let (login_req, login_data) = + login::login(credential_auth_token_hex).map_err(LoginResult::Other)?; + + login_inner_impl(login_req, login_data, io).await +} diff --git a/lib/account-client/src/logout.rs b/lib/account-client/src/logout.rs new file mode 100644 index 0000000..fa96ad6 --- /dev/null +++ b/lib/account-client/src/logout.rs @@ -0,0 +1,64 @@ +use accounts_shared::client::{logout::prepare_logout_request, machine_id::machine_uid}; +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`logout`] request. +#[derive(Error, Debug)] +pub enum LogoutResult { + /// Session was invalid, must login again. + #[error("The session was not valid anymore.")] + SessionWasInvalid, + /// A file system like error occurred. + /// This usually means the user was not yet logged in. + #[error("{0}")] + FsLikeError(FsLikeError), + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// Errors that are not handled explicitly. + #[error("Logging out failed: {0}")] + Other(anyhow::Error), +} + +impl From for LogoutResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for LogoutResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// Log out an existing session on the account server. +/// +/// # Errors +/// +/// If an error occurs this usually means that the session is not valid anymore. +pub async fn logout(io: &dyn Io) -> anyhow::Result<(), LogoutResult> { + logout_impl(io.into()).await +} + +async fn logout_impl(io: IoSafe<'_>) -> anyhow::Result<(), LogoutResult> { + // read session's key-pair + let key_pair = io.read_serialized_session_key_pair().await?; + + let hashed_hw_id = machine_uid().map_err(LogoutResult::Other)?; + + // do the logout request using the above private key + let msg = prepare_logout_request(hashed_hw_id, &key_pair.private_key, key_pair.public_key); + io.request_logout(msg) + .await? + .map_err(|err| LogoutResult::Other(err.into()))?; + + // remove the session's key pair + io.remove_serialized_session_key_pair().await?; + Ok(()) +} diff --git a/lib/account-client/src/logout_all.rs b/lib/account-client/src/logout_all.rs new file mode 100644 index 0000000..4ce539e --- /dev/null +++ b/lib/account-client/src/logout_all.rs @@ -0,0 +1,67 @@ +use accounts_shared::client::{logout_all, machine_id::machine_uid}; +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`logout_all`] request. +#[derive(Error, Debug)] +pub enum LogoutAllResult { + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// A fs like error occurred. + #[error("{0}")] + FsLikeError(FsLikeError), + /// Errors that are not handled explicitly. + #[error("Delete failed: {0}")] + Other(anyhow::Error), +} + +impl From for LogoutAllResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for LogoutAllResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// Delete all sessions of an account on the account server, except +/// for the current one. +pub async fn logout_all( + account_token_hex: String, + io: &dyn Io, +) -> anyhow::Result<(), LogoutAllResult> { + logout_all_impl(account_token_hex, io.into()).await +} + +async fn logout_all_impl( + account_token_hex: String, + io: IoSafe<'_>, +) -> anyhow::Result<(), LogoutAllResult> { + // read session's key-pair + let key_pair = io.read_serialized_session_key_pair().await?; + + let hashed_hw_id = machine_uid().map_err(LogoutAllResult::Other)?; + + let delete_req = logout_all::logout_all( + account_token_hex, + hashed_hw_id, + &key_pair.private_key, + key_pair.public_key, + ) + .map_err(LogoutAllResult::Other)?; + + io.request_logout_all(delete_req) + .await? + .map_err(|err| LogoutAllResult::Other(err.into()))?; + + Ok(()) +} diff --git a/lib/account-client/src/safe_interface.rs b/lib/account-client/src/safe_interface.rs new file mode 100644 index 0000000..06bb8ed --- /dev/null +++ b/lib/account-client/src/safe_interface.rs @@ -0,0 +1,331 @@ +use accounts_shared::{ + account_server::{ + account_info::AccountInfoResponse, account_token::AccountTokenError, + credential_auth_token::CredentialAuthTokenError, errors::Empty, login::LoginError, + result::AccountServerReqResult, sign::SignResponseSuccess, + }, + client::{ + account_data::AccountDataForClient, + account_info::AccountInfoRequest, + account_token::{AccountTokenEmailRequest, AccountTokenSteamRequest}, + credential_auth_token::{CredentialAuthTokenEmailRequest, CredentialAuthTokenSteamRequest}, + delete::DeleteRequest, + link_credential::LinkCredentialRequest, + login::LoginRequest, + logout::LogoutRequest, + logout_all::LogoutAllRequest, + sign::SignRequest, + unlink_credential::UnlinkCredentialRequest, + }, +}; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use async_trait::async_trait; +use serde::Deserialize; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, +}; + +/// Type safe version of [`Io`] +#[async_trait] +pub trait SafeIo: Sync + Send { + async fn request_credential_auth_email_token( + &self, + data: CredentialAuthTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_credential_auth_steam_token( + &self, + data: CredentialAuthTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_credential_auth_email_token_with_secret_key( + &self, + data: CredentialAuthTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_credential_auth_steam_token_with_secret_key( + &self, + data: CredentialAuthTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_login( + &self, + data: LoginRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_logout( + &self, + data: LogoutRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_sign( + &self, + data: SignRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_account_token_email( + &self, + data: AccountTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_account_token_email_secret( + &self, + data: AccountTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_account_token_steam( + &self, + data: AccountTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_account_token_steam_secret( + &self, + data: AccountTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_logout_all( + &self, + data: LogoutAllRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_delete_account( + &self, + data: DeleteRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_link_credential( + &self, + data: LinkCredentialRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_unlink_credential( + &self, + data: UnlinkCredentialRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn request_account_info( + &self, + data: AccountInfoRequest, + ) -> anyhow::Result, HttpLikeError>; + async fn download_account_server_certificates( + &self, + ) -> anyhow::Result>, Empty>, HttpLikeError>; + async fn write_serialized_session_key_pair( + &self, + file: &AccountDataForClient, + ) -> anyhow::Result<(), FsLikeError>; + async fn read_serialized_session_key_pair( + &self, + ) -> anyhow::Result; + async fn remove_serialized_session_key_pair(&self) -> anyhow::Result<(), FsLikeError>; +} + +pub struct IoSafe<'a> { + pub io: &'a dyn Io, +} + +impl<'a> IoSafe<'a> { + fn des_from_vec(data: Vec) -> anyhow::Result + where + for<'de> T: Deserialize<'de>, + { + let s = String::from_utf8(data).map_err(|err| HttpLikeError::Other(err.into()))?; + serde_json::from_str(s.as_str()) + .map_err(|_| HttpLikeError::Other(anyhow!("failed to parse json: {s}"))) + } +} + +impl<'a> From<&'a dyn Io> for IoSafe<'a> { + fn from(io: &'a dyn Io) -> Self { + Self { io } + } +} + +#[async_trait] +impl<'a> SafeIo for IoSafe<'a> { + async fn request_credential_auth_email_token( + &self, + data: CredentialAuthTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_credential_auth_email_token(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_credential_auth_steam_token( + &self, + data: CredentialAuthTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError> + { + let res = self + .io + .request_credential_auth_steam_token(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_credential_auth_email_token_with_secret_key( + &self, + data: CredentialAuthTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_credential_auth_email_token_with_secret_key( + serde_json::to_string(&data)?.into_bytes(), + ) + .await?; + Self::des_from_vec(res) + } + async fn request_credential_auth_steam_token_with_secret_key( + &self, + data: CredentialAuthTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError> + { + let res = self + .io + .request_credential_auth_steam_token_with_secret_key( + serde_json::to_string(&data)?.into_bytes(), + ) + .await?; + Self::des_from_vec(res) + } + async fn request_login( + &self, + data: LoginRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_login(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_logout( + &self, + data: LogoutRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_logout(serde_json::to_string(&data)?.into_bytes()) + .await?; + + Self::des_from_vec(res) + } + async fn request_sign( + &self, + data: SignRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_sign(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_account_token_email( + &self, + data: AccountTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_account_token_email(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_account_token_email_secret( + &self, + data: AccountTokenEmailRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_account_token_email_secret(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_account_token_steam( + &self, + data: AccountTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_account_token_steam(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_account_token_steam_secret( + &self, + data: AccountTokenSteamRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_account_token_steam_secret(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_logout_all( + &self, + data: LogoutAllRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_logout_all(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_delete_account( + &self, + data: DeleteRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_delete_account(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_link_credential( + &self, + data: LinkCredentialRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_link_credential(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_unlink_credential( + &self, + data: UnlinkCredentialRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_unlink_credential(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn request_account_info( + &self, + data: AccountInfoRequest, + ) -> anyhow::Result, HttpLikeError> { + let res = self + .io + .request_account_info(serde_json::to_string(&data)?.into_bytes()) + .await?; + Self::des_from_vec(res) + } + async fn download_account_server_certificates( + &self, + ) -> anyhow::Result>, Empty>, HttpLikeError> { + let res = self.io.download_account_server_certificates().await?; + + Self::des_from_vec(res) + } + async fn write_serialized_session_key_pair( + &self, + file: &AccountDataForClient, + ) -> anyhow::Result<(), FsLikeError> { + self.io + .write_serialized_session_key_pair( + serde_json::to_string(file) + .map_err(|err| FsLikeError::Other(err.into()))? + .into_bytes(), + ) + .await + } + async fn read_serialized_session_key_pair( + &self, + ) -> anyhow::Result { + Ok( + serde_json::from_slice(&self.io.read_serialized_session_key_pair().await?) + .map_err(|err| FsLikeError::Other(err.into()))?, + ) + } + async fn remove_serialized_session_key_pair(&self) -> anyhow::Result<(), FsLikeError> { + self.io.remove_serialized_session_key_pair().await + } +} diff --git a/lib/account-client/src/sign.rs b/lib/account-client/src/sign.rs new file mode 100644 index 0000000..32accd7 --- /dev/null +++ b/lib/account-client/src/sign.rs @@ -0,0 +1,117 @@ +use accounts_shared::client::{ + account_data::AccountDataForClient, machine_id::machine_uid, sign::prepare_sign_request, +}; +use anyhow::anyhow; +use thiserror::Error; +use x509_parser::oid_registry::asn1_rs::FromDer; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`sign`] request. +#[derive(Error, Debug)] +pub enum SignResult { + /// Session was invalid, must login again. + #[error("The session was not valid anymore.")] + SessionWasInvalid, + /// A file system like error occurred. + /// This usually means the user was not yet logged in. + #[error("{0}")] + FsLikeError(FsLikeError), + /// A http like error occurred. + #[error("{err}")] + HttpLikeError { + /// The actual error message + err: HttpLikeError, + /// The account data that the client could use as fallback + account_data: AccountDataForClient, + }, + /// Errors that are not handled explicitly. + #[error("Signing failed: {err}")] + Other { + /// The actual error message + err: anyhow::Error, + /// The account data that the client could use as fallback + account_data: AccountDataForClient, + }, +} + +impl From for SignResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// The sign data contains the signed certificate +/// by the account server, which the client can send +/// to a game server to proof account relationship. +#[derive(Debug, Clone)] +pub struct SignData { + /// Certificate that was signed by the account server to proof that + /// the client owns the account. + /// The cert is in der format. + pub certificate_der: Vec, + /// The account data for this session. + pub session_key_pair: AccountDataForClient, +} + +/// Sign an existing session on the account server. +/// +/// The account server will respond with a certificate, +/// that can be used to verify account ownership on game servers. +/// __IMPORTANT__: Never share this certificate with anyone. +/// Best is to not even save it to disk, re-sign instead. +/// +/// # Errors +/// +/// If an error occurs this usually means that the session is not valid anymore. +pub async fn sign(io: &dyn Io) -> anyhow::Result { + sign_impl(io.into()).await +} + +async fn sign_impl(io: IoSafe<'_>) -> anyhow::Result { + // read session's key-pair + let key_pair = io.read_serialized_session_key_pair().await?; + + let hashed_hw_id = machine_uid().map_err(|err| SignResult::Other { + account_data: key_pair.clone(), + err, + })?; + + // do the sign request using the above private key + let msg = prepare_sign_request(hashed_hw_id, &key_pair.private_key, key_pair.public_key); + let sign_res = io + .request_sign(msg) + .await + .map_err(|err| SignResult::HttpLikeError { + account_data: key_pair.clone(), + err, + })? + .map_err(|err| SignResult::Other { + err: err.into(), + account_data: key_pair.clone(), + })?; + let certificate = { + x509_parser::certificate::X509Certificate::from_der(&sign_res.cert_der) + .is_ok() + .then_some(sign_res.cert_der) + }; + + certificate.map_or_else( + || { + Err(SignResult::Other { + err: anyhow!("the certificate is not in a valid der format"), + account_data: key_pair.clone(), + }) + }, + |certificate| { + Ok(SignData { + certificate_der: certificate, + session_key_pair: key_pair.clone(), + }) + }, + ) +} diff --git a/lib/account-client/src/unlink_credential.rs b/lib/account-client/src/unlink_credential.rs new file mode 100644 index 0000000..1dabf12 --- /dev/null +++ b/lib/account-client/src/unlink_credential.rs @@ -0,0 +1,64 @@ +use accounts_shared::{ + account_server::errors::{AccountServerRequestError, Empty}, + client::unlink_credential, +}; + +use thiserror::Error; + +use crate::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, + safe_interface::{IoSafe, SafeIo}, +}; + +/// The result of a [`unlink_credential`] request. +#[derive(Error, Debug)] +pub enum UnlinkCredentialResult { + /// A http like error occurred. + #[error("{0}")] + HttpLikeError(HttpLikeError), + /// A fs like error occurred. + #[error("{0}")] + FsLikeError(FsLikeError), + /// The account server responded with an error. + #[error("{0:?}")] + AccountServerRequstError(AccountServerRequestError), + /// Errors that are not handled explicitly. + #[error("Unlinking credential failed: {0}")] + Other(anyhow::Error), +} + +impl From for UnlinkCredentialResult { + fn from(value: HttpLikeError) -> Self { + Self::HttpLikeError(value) + } +} + +impl From for UnlinkCredentialResult { + fn from(value: FsLikeError) -> Self { + Self::FsLikeError(value) + } +} + +/// Unlink a credential from an account. +/// If the credential is the last one linked, this function fails. +pub async fn unlink_credential( + credential_auth_token_hex: String, + io: &dyn Io, +) -> anyhow::Result<(), UnlinkCredentialResult> { + unlink_credential_impl(credential_auth_token_hex, io.into()).await +} + +async fn unlink_credential_impl( + credential_auth_token_hex: String, + io: IoSafe<'_>, +) -> anyhow::Result<(), UnlinkCredentialResult> { + io.request_unlink_credential( + unlink_credential::unlink_credential(credential_auth_token_hex) + .map_err(UnlinkCredentialResult::Other)?, + ) + .await? + .map_err(UnlinkCredentialResult::AccountServerRequstError)?; + + Ok(()) +} diff --git a/lib/account-game-server/Cargo.toml b/lib/account-game-server/Cargo.toml new file mode 100644 index 0000000..22f0457 --- /dev/null +++ b/lib/account-game-server/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "account-game-server" +version = "0.1.0" +edition = "2021" +authors = ["Jupeyy"] +license = "MIT OR Apache-2.0" +description = "The account related operations of a game server, that want to manage accounts." + +[dependencies] +accounts-types = { version = "0.1.0", path = "../accounts-types" } +accounts-shared = { version = "0.1.0", path = "../accounts-shared" } +account-sql = { version = "0.1.0", path = "../account-sql" } + +# https://github.com/launchbadge/sqlx/issues/2636 +sqlx = { version = "=0.6.3", features = ["mysql", "any", "runtime-tokio-rustls", "chrono"] } +anyhow = { version = "1.0.86", features = ["backtrace"] } +async-trait = "0.1.81" +thiserror = "1.0.63" diff --git a/lib/account-game-server/src/auto_login.rs b/lib/account-game-server/src/auto_login.rs new file mode 100644 index 0000000..90a748e --- /dev/null +++ b/lib/account-game-server/src/auto_login.rs @@ -0,0 +1,74 @@ +pub(crate) mod queries; + +use std::sync::Arc; + +use account_sql::query::Query; +use accounts_shared::game_server::user_id::UserId; +use accounts_types::account_id::AccountId; +use sqlx::Acquire; +use thiserror::Error; + +use crate::shared::Shared; + +use self::queries::RegisterUser; + +/// The error type if registering to the game server fails. +#[derive(Error, Debug)] +pub enum AutoLoginError { + /// A database error happened. + #[error("{0}")] + Database(anyhow::Error), +} + +/// The prefix used for the default name generation. +pub const DEFAULT_NAME_PREFIX: &str = "autouser"; + +/// The default name for a given account. +pub fn default_name(account_id: &AccountId) -> String { + format!("{DEFAULT_NAME_PREFIX}{account_id}") +} + +/// Logs in the user. +/// +/// Might create a new user row if the user didn't exist before. +/// Returns `true` if an account was created, which usually happens +/// if the user wasn't registered before and has a valid account id. +/// +/// If the user has no account_id (account-server), then `Ok(false)` is returned. +/// +/// Note: If this function returns `true`, the game server can assume +/// that the public key information in [`UserId`] belongs to this account, +/// thus it could link database entries where it only had the public key +/// information to the account now. +pub async fn auto_login( + shared: Arc, + pool: &sqlx::AnyPool, + user_id: &UserId, +) -> anyhow::Result { + if let Some(account_id) = &user_id.account_id { + let mut pool_con = pool + .acquire() + .await + .map_err(|err| AutoLoginError::Database(err.into()))?; + let con = pool_con + .acquire() + .await + .map_err(|err| AutoLoginError::Database(err.into()))?; + + let name = default_name(account_id); + let qry = RegisterUser { + account_id, + default_name: &name, + }; + + let res = qry + .query(&shared.db.register_user_statement) + .execute(&mut *con) + .await + .map_err(|err| AutoLoginError::Database(err.into()))?; + + Ok(res.rows_affected() >= 1) + } else { + Ok(false) + } +} diff --git a/lib/account-game-server/src/auto_login/mysql/try_insert_user.sql b/lib/account-game-server/src/auto_login/mysql/try_insert_user.sql new file mode 100644 index 0000000..f541fa6 --- /dev/null +++ b/lib/account-game-server/src/auto_login/mysql/try_insert_user.sql @@ -0,0 +1,12 @@ +INSERT + IGNORE INTO user ( + name, + account_id, + create_time + ) +VALUES + ( + ?, + ?, + UTC_TIMESTAMP() + ); diff --git a/lib/account-game-server/src/auto_login/queries.rs b/lib/account-game-server/src/auto_login/queries.rs new file mode 100644 index 0000000..6a62ad9 --- /dev/null +++ b/lib/account-game-server/src/auto_login/queries.rs @@ -0,0 +1,42 @@ +use account_sql::query::Query; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use async_trait::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +/// A query that tries to insert a new user in the database. +/// On failure it does nothing. +#[derive(Debug)] +pub struct RegisterUser<'a> { + /// the account id of the user, see [`AccountId`] + pub account_id: &'a AccountId, + /// the default name of the user + pub default_name: &'a str, +} + +#[async_trait] +impl<'a> Query<()> for RegisterUser<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/try_insert_user.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + let account_id = self.account_id; + + statement.query().bind(self.default_name).bind(account_id) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!( + "Data rows are not supported for this query. + You probably want to check affected rows instead." + )) + } +} diff --git a/lib/account-game-server/src/db.rs b/lib/account-game-server/src/db.rs new file mode 100644 index 0000000..a074068 --- /dev/null +++ b/lib/account-game-server/src/db.rs @@ -0,0 +1,11 @@ +use sqlx::any::AnyStatement; + +/// Shared data for a db connection +pub struct DbConnectionShared { + /// Prepared statement for + /// [`crate::auto_login::queries::RegisterUser`] + pub register_user_statement: AnyStatement<'static>, + /// Prepared statement for + /// [`crate::rename::queries::RenameUser`] + pub try_rename_statement: AnyStatement<'static>, +} diff --git a/lib/account-game-server/src/lib.rs b/lib/account-game-server/src/lib.rs new file mode 100644 index 0000000..d8ede43 --- /dev/null +++ b/lib/account-game-server/src/lib.rs @@ -0,0 +1,29 @@ +//! This crate contains a base implementation for +//! a game server to do account related operations. +//! It helps sending data, storing results persistently. +//! This crate is not intended for creating UI, +//! any game logic, database implementations nor knowing about the communication details +//! (be it UDP, HTTP or other stuff). +//! It uses interfaces to abstract such concepts away. + +#![deny(missing_docs)] +#![deny(warnings)] +#![deny(clippy::nursery)] +#![deny(clippy::all)] + +/// Data types and operations related to +/// logging in a user to the game server. +pub mod auto_login; +/// Data types used in the game server +/// for a database connection. +pub mod db; +/// Helpers to prepare the game server. +pub mod prepare; +/// Data types and operations related to +/// renaming a user on the game server. +pub mod rename; +/// Setup for databases and other stuff related to game servers. +pub mod setup; +/// Shared data that is used in the game +/// server implementation. +pub mod shared; diff --git a/lib/account-game-server/src/prepare.rs b/lib/account-game-server/src/prepare.rs new file mode 100644 index 0000000..522cf1b --- /dev/null +++ b/lib/account-game-server/src/prepare.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use account_sql::query::Query; +use sqlx::Acquire; + +use crate::{ + auto_login::queries::RegisterUser, db::DbConnectionShared, rename::queries::RenameUser, + shared::Shared, +}; + +async fn prepare_statements(pool: &sqlx::AnyPool) -> anyhow::Result { + let mut pool_con = pool.acquire().await?; + let con = pool_con.acquire().await?; + + Ok(DbConnectionShared { + register_user_statement: RegisterUser::prepare(con).await?, + try_rename_statement: RenameUser::prepare(con).await?, + }) +} + +/// Prepare shared data used in the game server's implementation +pub async fn prepare(pool: &sqlx::AnyPool) -> anyhow::Result> { + Ok(Arc::new(Shared { + db: prepare_statements(pool).await?, + })) +} diff --git a/lib/account-game-server/src/rename.rs b/lib/account-game-server/src/rename.rs new file mode 100644 index 0000000..96ed9fd --- /dev/null +++ b/lib/account-game-server/src/rename.rs @@ -0,0 +1,93 @@ +pub(crate) mod queries; + +use std::sync::Arc; + +use account_sql::{is_duplicate_entry, query::Query}; +use accounts_shared::game_server::user_id::UserId; +use sqlx::Acquire; +use thiserror::Error; + +use crate::{ + auto_login::{default_name, DEFAULT_NAME_PREFIX}, + shared::Shared, +}; + +use self::queries::RenameUser; + +/// The error type if registering to the game server fails. +#[derive(Error, Debug)] +pub enum RenameError { + /// A database error happened. + #[error("{0}")] + Database(anyhow::Error), + /// only specific ascii characters are allowed. + #[error("only lowercase ascii characters [a-z], [0-9], `_` are allowed.")] + InvalidAscii, + /// some names are not allowed. + #[error("a user name is not allowed to start with \"autouser\".")] + ReservedName, + /// the user name is already taken + #[error("a user with that name already exists.")] + NameAlreadyExists, + /// the user name is too short or too long + #[error("a user must be at least 3 characters and at most 32 characters long.")] + NameLengthInvalid, +} + +/// Renames a user. +/// Returns `true` if the rename was successful. +/// Returns `false` if the user had no account. +pub async fn rename( + shared: Arc, + pool: &sqlx::AnyPool, + user_id: &UserId, + name: &str, +) -> anyhow::Result { + if let Some(account_id) = &user_id.account_id { + name.chars() + .all(|char| { + (char.is_ascii_alphanumeric() && (char.is_ascii_lowercase() || char.is_numeric())) + || char == '_' + }) + .then_some(()) + .ok_or_else(|| RenameError::InvalidAscii)?; + let len = name.chars().count(); + (3..=32) + .contains(&len) + .then_some(()) + .ok_or_else(|| RenameError::NameLengthInvalid)?; + // renaming back to the default name is allowed + (!name.starts_with(DEFAULT_NAME_PREFIX) || name == default_name(account_id)) + .then_some(()) + .ok_or_else(|| RenameError::ReservedName)?; + + let mut pool_con = pool + .acquire() + .await + .map_err(|err| RenameError::Database(err.into()))?; + let con = pool_con + .acquire() + .await + .map_err(|err| RenameError::Database(err.into()))?; + + let qry = RenameUser { account_id, name }; + + let res = qry + .query(&shared.db.try_rename_statement) + .execute(&mut *con) + .await; + + if is_duplicate_entry(&res) { + return Err(RenameError::NameAlreadyExists); + } + let res = res.map_err(|err| RenameError::Database(err.into()))?; + + (res.rows_affected() >= 1) + .then_some(()) + .ok_or_else(|| RenameError::NameAlreadyExists)?; + + Ok(true) + } else { + Ok(false) + } +} diff --git a/lib/account-game-server/src/rename/mysql/try_rename.sql b/lib/account-game-server/src/rename/mysql/try_rename.sql new file mode 100644 index 0000000..e6cc944 --- /dev/null +++ b/lib/account-game-server/src/rename/mysql/try_rename.sql @@ -0,0 +1,6 @@ +UPDATE + user +SET + user.name = ? +WHERE + user.account_id = ?; diff --git a/lib/account-game-server/src/rename/queries.rs b/lib/account-game-server/src/rename/queries.rs new file mode 100644 index 0000000..94fcc50 --- /dev/null +++ b/lib/account-game-server/src/rename/queries.rs @@ -0,0 +1,42 @@ +use account_sql::query::Query; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use async_trait::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +/// A query that tries to insert a new user in the database. +/// On failure it does nothing. +#[derive(Debug)] +pub struct RenameUser<'a> { + /// the id of the user's account, see [`AccountId`] + pub account_id: &'a AccountId, + /// the new name in pure ascii. + pub name: &'a str, +} + +#[async_trait] +impl<'a> Query<()> for RenameUser<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/try_rename.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + let account_id = self.account_id; + + statement.query().bind(self.name).bind(account_id) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!( + "Data rows are not supported for this query. + You probably want to check affected rows instead." + )) + } +} diff --git a/lib/account-game-server/src/setup.rs b/lib/account-game-server/src/setup.rs new file mode 100644 index 0000000..4daf360 --- /dev/null +++ b/lib/account-game-server/src/setup.rs @@ -0,0 +1,73 @@ +use account_sql::version::get_version; +use account_sql::version::set_version; +use sqlx::Acquire; +use sqlx::AnyConnection; +use sqlx::Connection; +use sqlx::Executor; +use sqlx::Statement; + +const VERSION_NAME: &str = "account-game-server"; + +async fn setup_version1_mysql(con: &mut AnyConnection) -> anyhow::Result<()> { + // first create all statements (syntax check) + let user = con.prepare(include_str!("setup/mysql/user.sql")).await?; + + // afterwards actually create tables + user.query().execute(&mut *con).await?; + + set_version(con, VERSION_NAME, 1).await?; + + Ok(()) +} + +async fn setup_version1(con: &mut AnyConnection) -> anyhow::Result<()> { + match con.kind() { + sqlx::any::AnyKind::MySql => setup_version1_mysql(con).await, + } +} + +/// Sets up all mysql tables required for a game server user +pub async fn setup(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + let mut pool_con = pool.acquire().await?; + let con = pool_con.acquire().await?; + + con.transaction(|con| { + Box::pin(async move { + let version = get_version(con, VERSION_NAME).await?; + if version < 1 { + setup_version1(&mut *con).await?; + } + + anyhow::Ok(()) + }) + }) + .await +} + +async fn delete_mysql(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + let mut pool_con = pool.acquire().await?; + let con = pool_con.acquire().await?; + + // first create all statements (syntax check) + // delete in reverse order to creating + let user = con + .prepare(include_str!("setup/mysql/delete/user.sql")) + .await?; + + // afterwards actually drop tables + let user = user.query().execute(&mut *con).await; + + let _ = set_version(con, VERSION_NAME, 0).await; + + // handle errors at once + user?; + + Ok(()) +} + +/// Drop all tables related to a game server mysql setup +pub async fn delete(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + match pool.any_kind() { + sqlx::any::AnyKind::MySql => delete_mysql(pool).await, + } +} diff --git a/lib/account-game-server/src/setup/mysql/delete/user.sql b/lib/account-game-server/src/setup/mysql/delete/user.sql new file mode 100644 index 0000000..94dc7a2 --- /dev/null +++ b/lib/account-game-server/src/setup/mysql/delete/user.sql @@ -0,0 +1 @@ +DROP TABLE user; diff --git a/lib/account-game-server/src/setup/mysql/user.sql b/lib/account-game-server/src/setup/mysql/user.sql new file mode 100644 index 0000000..0953760 --- /dev/null +++ b/lib/account-game-server/src/setup/mysql/user.sql @@ -0,0 +1,10 @@ +CREATE TABLE user ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(32) NOT NULL COLLATE ascii_bin, + account_id BIGINT NOT NULL, + -- UTC timestamp! (UTC_TIMESTAMP()) + create_time DATETIME NOT NULL, + PRIMARY KEY(id), + UNIQUE KEY(name), + UNIQUE KEY(account_id) +); diff --git a/lib/account-game-server/src/shared.rs b/lib/account-game-server/src/shared.rs new file mode 100644 index 0000000..3a8e7a2 --- /dev/null +++ b/lib/account-game-server/src/shared.rs @@ -0,0 +1,7 @@ +use crate::db::DbConnectionShared; +/// Various data that is shared for the async +/// implementations +pub struct Shared { + /// Prepared db statements + pub db: DbConnectionShared, +} diff --git a/lib/account-sql/Cargo.toml b/lib/account-sql/Cargo.toml new file mode 100644 index 0000000..a77bfe0 --- /dev/null +++ b/lib/account-sql/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "account-sql" +version = "0.1.0" +edition = "2021" +authors = ["Jupeyy"] +license = "MIT OR Apache-2.0" +description = "Helpers for SQL databases." + +[dependencies] +# https://github.com/launchbadge/sqlx/issues/2636 +sqlx = { version = "=0.6.3", features = ["mysql", "any", "runtime-tokio-rustls", "chrono"] } +async-trait = "0.1.81" +anyhow = { version = "1.0.86", features = ["backtrace"] } diff --git a/lib/account-sql/src/lib.rs b/lib/account-sql/src/lib.rs new file mode 100644 index 0000000..2604bac --- /dev/null +++ b/lib/account-sql/src/lib.rs @@ -0,0 +1,30 @@ +//! This crate contains a interfaces for common tasks +//! on the database. + +#![deny(missing_docs)] +#![deny(warnings)] +#![deny(clippy::nursery)] +#![deny(clippy::all)] + +use sqlx::{any::AnyQueryResult, Error}; + +/// Everything related to queries +pub mod query; +/// Everything related to versioning table setups +pub mod version; + +/// Checks if the query result resulted in an error that indicates +/// a duplicate entry. +pub fn is_duplicate_entry(res: &Result) -> bool { + res.as_ref().is_err_and(|err| { + if let sqlx::Error::Database(err) = err { + [23000, 23001].contains( + &err.code() + .and_then(|code| code.parse::().ok()) + .unwrap_or_default(), + ) + } else { + false + } + }) +} diff --git a/lib/account-sql/src/query.rs b/lib/account-sql/src/query.rs new file mode 100644 index 0000000..7904c02 --- /dev/null +++ b/lib/account-sql/src/query.rs @@ -0,0 +1,38 @@ +use async_trait::async_trait; +use sqlx::any::{AnyKind, AnyRow}; + +/// An interface for queries to allow converting them to various database implementations +#[async_trait] +pub trait Query { + /// MySQL version of [`Query::prepare`]. + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result>; + + /// Prepare a statement to be later used by [`Query::query`]. + async fn prepare( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + match connection.kind() { + AnyKind::MySql => Self::prepare_mysql(connection).await, + //_ => Err(anyhow!("database backend not implemented.")), + } + } + + /// Get a query with all arguments bound already, ready to be fetched. + fn query_mysql<'a>( + &'a self, + statement: &'a sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'a, sqlx::Any, sqlx::any::AnyArguments<'a>>; + + /// Get a query with all arguments bound already, ready to be fetched. + fn query<'a>( + &'a self, + statement: &'a sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'a, sqlx::Any, sqlx::any::AnyArguments<'a>> { + self.query_mysql(statement) + } + + /// Gets the row data for a result row of this query + fn row_data(row: &AnyRow) -> anyhow::Result; +} diff --git a/lib/account-sql/src/version.rs b/lib/account-sql/src/version.rs new file mode 100644 index 0000000..994231b --- /dev/null +++ b/lib/account-sql/src/version.rs @@ -0,0 +1,119 @@ +use sqlx::Acquire; +use sqlx::AnyConnection; +use sqlx::Executor; +use sqlx::Row; +use sqlx::Statement; + +async fn try_setup_mysql(con: &mut AnyConnection) -> anyhow::Result<()> { + // first create all statements (syntax check) + let version = con + .prepare(include_str!("version/mysql/version.sql")) + .await?; + + // afterwards actually create tables + version.query().execute(&mut *con).await?; + + Ok(()) +} + +async fn get_or_set_version_mysql(con: &mut AnyConnection, name: &str) -> anyhow::Result { + // first create all statements (syntax check) + let get_version = con + .prepare(include_str!("version/mysql/get_version.sql")) + .await?; + let set_version = con + .prepare(include_str!("version/mysql/set_version.sql")) + .await?; + + let name = name.to_string(); + + // afterwards actually create tables + if let Some(row) = get_version + .query() + .bind(&name) + .fetch_optional(&mut *con) + .await? + { + anyhow::Ok(row.try_get("version")?) + } else { + // insert new entry + set_version + .query() + .bind(&name) + .bind(0) + .bind(0) + .execute(&mut *con) + .await?; + anyhow::Ok(0) + } +} + +async fn set_version_mysql( + con: &mut AnyConnection, + name: &str, + version: i64, +) -> anyhow::Result<()> { + // first create all statements (syntax check) + let set_version = con + .prepare(include_str!("version/mysql/set_version.sql")) + .await?; + + Ok(set_version + .query() + .bind(name) + .bind(version) + .bind(version) + .execute(&mut *con) + .await + .map(|_| ())?) +} + +/// Use this function to obtain the current version number. +/// +/// If the version table does not exist, sets up the version table. +/// The version table can be used to easily upgrade existing tables to a new +/// version, without manually doing it by hand. +pub async fn get_version(con: &mut AnyConnection, name: &str) -> anyhow::Result { + match con.kind() { + sqlx::any::AnyKind::MySql => { + // try setup + try_setup_mysql(con).await?; + get_or_set_version_mysql(con, name).await + } + } +} + +/// After your setup is done, set the version to your current setup script. +/// This can (and should) be called inside a transaction +pub async fn set_version(con: &mut AnyConnection, name: &str, version: i64) -> anyhow::Result<()> { + match con.kind() { + sqlx::any::AnyKind::MySql => set_version_mysql(con, name, version).await, + } +} + +async fn delete_mysql(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + let mut pool_con = pool.acquire().await?; + let con = pool_con.acquire().await?; + + // first create all statements (syntax check) + // delete in reverse order to creating + let version = con + .prepare(include_str!("version/mysql/delete/version.sql")) + .await?; + + // afterwards actually drop tables + let version = version.query().execute(&mut *con).await; + + // handle errors at once + version?; + + Ok(()) +} + +/// Drop the version table... +/// Warning: This is usually not recommended. +pub async fn delete(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + match pool.any_kind() { + sqlx::any::AnyKind::MySql => delete_mysql(pool).await, + } +} diff --git a/lib/account-sql/src/version/mysql/delete/version.sql b/lib/account-sql/src/version/mysql/delete/version.sql new file mode 100644 index 0000000..ad81657 --- /dev/null +++ b/lib/account-sql/src/version/mysql/delete/version.sql @@ -0,0 +1 @@ +DROP TABLE table_infra_version; diff --git a/lib/account-sql/src/version/mysql/get_version.sql b/lib/account-sql/src/version/mysql/get_version.sql new file mode 100644 index 0000000..b424583 --- /dev/null +++ b/lib/account-sql/src/version/mysql/get_version.sql @@ -0,0 +1,6 @@ +SELECT + table_infra_version.version +FROM + table_infra_version +WHERE + table_infra_version.name = ?; diff --git a/lib/account-sql/src/version/mysql/set_version.sql b/lib/account-sql/src/version/mysql/set_version.sql new file mode 100644 index 0000000..bb09f1d --- /dev/null +++ b/lib/account-sql/src/version/mysql/set_version.sql @@ -0,0 +1,15 @@ +INSERT INTO + table_infra_version ( + name, + version, + last_update_time + ) +VALUES + ( + ?, + ?, + UTC_TIMESTAMP() + ) ON DUPLICATE KEY +UPDATE + table_infra_version.version = ?, + table_infra_version.last_update_time = UTC_TIMESTAMP(); diff --git a/lib/account-sql/src/version/mysql/version.sql b/lib/account-sql/src/version/mysql/version.sql new file mode 100644 index 0000000..9760a02 --- /dev/null +++ b/lib/account-sql/src/version/mysql/version.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS table_infra_version ( + id BIGINT NOT NULL AUTO_INCREMENT, + -- the group name of the tables to be created + name VARCHAR(64) NOT NULL COLLATE ascii_bin, + version BIGINT NOT NULL, + -- UTC timestamp! (UTC_TIMESTAMP()) of the + -- last time this table was update + last_update_time DATETIME NOT NULL, + PRIMARY KEY(id), + UNIQUE KEY(name) +); diff --git a/lib/accounts-shared/Cargo.toml b/lib/accounts-shared/Cargo.toml new file mode 100644 index 0000000..fbfcbc2 --- /dev/null +++ b/lib/accounts-shared/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "accounts-shared" +version = "0.1.0" +edition = "2021" +authors = ["Jupeyy"] +license = "MIT OR Apache-2.0" +description = "Most account related types shared accross all libs & bins." + +[dependencies] +accounts-types = { version = "0.1.0", path = "../accounts-types" } + +argon2 = "0.5.3" +ed25519-dalek = { version = "2.1.1", features = ["serde", "rand_core", "pkcs8", "pem"] } +rand = { version = "0.8.5", features = ["getrandom"], default-features = false } +anyhow = { version = "1.0.86", features = ["backtrace"] } +serde = { version = "1.0.208", features = ["derive"] } +email_address = { version = "0.2.9", features = ["serde"] } +generic-array = { version = "1.1.0", features = ["serde"] } +rcgen = { version = "0.13.1" } +hex = "0.4.3" +chrono = { version = "0.4.38", features = ["serde"] } +x509-cert = { version = "0.2.5" } +spki = { version = "0.7.3", features = ["fingerprint"] } +const-oid = "0.9.6" +der = { version = "0.7.9", features = ["derive"] } +ecdsa = { version = "0.16.9", features = ["digest", "pem"] } +p256 = "0.13.2" +thiserror = "1.0.63" +url = { version = "2.5.2", features = ["serde"] } +strum = { version = "0.26.3", features = ["derive"] } + +[target.'cfg(not(any(target_arch = "wasm32", target_os = "android")))'.dependencies] +machine-uid = "0.5.3" diff --git a/lib/accounts-shared/src/account_server/account_info.rs b/lib/accounts-shared/src/account_server/account_info.rs new file mode 100644 index 0000000..7529de0 --- /dev/null +++ b/lib/accounts-shared/src/account_server/account_info.rs @@ -0,0 +1,22 @@ +use accounts_types::account_id::AccountId; +use serde::{Deserialize, Serialize}; + +/// A linked credential type of an account +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CredentialType { + /// The partial readable email as string + Email(String), + /// The steam id + Steam(i64), +} + +/// The response of an account info request from the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountInfoResponse { + /// The account id of the account + pub account_id: AccountId, + /// The UTC creation date of the account + pub creation_date: chrono::DateTime, + /// the credentials linked to this account + pub credentials: Vec, +} diff --git a/lib/accounts-shared/src/account_server/account_token.rs b/lib/accounts-shared/src/account_server/account_token.rs new file mode 100644 index 0000000..906d8db --- /dev/null +++ b/lib/accounts-shared/src/account_server/account_token.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +/// The response of a account token request by the client. +#[derive(Debug, Error, Clone, Serialize, Deserialize)] +pub enum AccountTokenError { + /// Token invalid, probably timed out + #[error("Because of spam you have to visit this web page to continue: {url}.")] + WebValidationProcessNeeded { + /// The url the client has to visit in order to continue + url: Url, + }, +} diff --git a/lib/accounts-shared/src/account_server/cert_account_ext.rs b/lib/accounts-shared/src/account_server/cert_account_ext.rs new file mode 100644 index 0000000..4ca71d0 --- /dev/null +++ b/lib/accounts-shared/src/account_server/cert_account_ext.rs @@ -0,0 +1,36 @@ +use accounts_types::account_id::AccountId; +use const_oid::{AssociatedOid, ObjectIdentifier}; +use serde::{Deserialize, Serialize}; +use x509_cert::{ + ext::{AsExtension, Extension}, + name::Name, +}; + +/// The inner data type of the account extension. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, der::Sequence)] +pub struct AccountCertData { + /// account id of the client. + pub account_id: AccountId, + /// The time offset to the creation date in UTC format + /// since the UNIX epoch in milliseconds. + pub utc_time_since_unix_epoch_millis: i64, +} + +/// The x509 extension that holds the account data. +#[derive(Debug, Clone, Default, PartialEq, Eq, der::Sequence)] +pub struct AccountCertExt { + /// actual account data, see [`AccountCertData`] + pub data: AccountCertData, +} + +impl AssociatedOid for AccountCertExt { + /// 1.3.6.1.4.1.0 is some random valid OID. + /// DD-Acc as ASCII code points. + const OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.0.68.68.45.65.99.99"); +} + +impl AsExtension for AccountCertExt { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} diff --git a/lib/accounts-shared/src/account_server/certs.rs b/lib/accounts-shared/src/account_server/certs.rs new file mode 100644 index 0000000..379449a --- /dev/null +++ b/lib/accounts-shared/src/account_server/certs.rs @@ -0,0 +1,4 @@ +/// array of certificates in der format that a game server +/// can download to verify certificates for clients signed +/// by the account server. +pub type AccountServerCertificates = Vec; diff --git a/lib/accounts-shared/src/account_server/credential_auth_token.rs b/lib/accounts-shared/src/account_server/credential_auth_token.rs new file mode 100644 index 0000000..21a0f50 --- /dev/null +++ b/lib/accounts-shared/src/account_server/credential_auth_token.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +/// The response of a credential auth token request by the client. +#[derive(Debug, Error, Clone, Serialize, Deserialize)] +pub enum CredentialAuthTokenError { + /// Token invalid, probably timed out + #[error("Because of spam you have to visit this web page to continue: {url}.")] + WebValidationProcessNeeded { + /// The url the client has to visit in order to continue + url: Url, + }, +} diff --git a/lib/accounts-shared/src/account_server/errors.rs b/lib/accounts-shared/src/account_server/errors.rs new file mode 100644 index 0000000..955dcca --- /dev/null +++ b/lib/accounts-shared/src/account_server/errors.rs @@ -0,0 +1,49 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// An error related to validating if a +/// request is allowed on the account server. +#[derive(Error, Debug, Clone, Serialize, Deserialize)] +pub enum AccountServerRequestError { + /// The request failed because + /// the client is rate limited. + #[error("{0}")] + RateLimited(String), + /// Banned because of using a blocked VPN. + #[error("{0}")] + VpnBan(String), + /// Any kind of layer reported to block this connection. + #[error("{0}")] + Other(String), + /// Database errors or similar. + #[error("{target}: {err}. Bt: {bt}")] + Unexpected { + /// Where the error happened + target: String, + /// The error as string + err: String, + /// A backtrace. + bt: String, + }, + /// Error caused by the logic. + #[error("{0}")] + LogicError(E), +} + +/// Empty logic error wrapper, which implements display +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Empty; + +impl From<()> for Empty { + fn from(_value: ()) -> Self { + Self + } +} + +impl Display for Empty { + fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} diff --git a/lib/accounts-shared/src/account_server/login.rs b/lib/accounts-shared/src/account_server/login.rs new file mode 100644 index 0000000..bc39728 --- /dev/null +++ b/lib/accounts-shared/src/account_server/login.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// The response of a login request by the client. +#[derive(Debug, Error, Clone, Serialize, Deserialize)] +pub enum LoginError { + /// Token invalid, probably timed out + #[error("The provided token is not valid anymore.")] + TokenInvalid, +} diff --git a/lib/accounts-shared/src/account_server/mod.rs b/lib/accounts-shared/src/account_server/mod.rs new file mode 100644 index 0000000..6a56fa6 --- /dev/null +++ b/lib/accounts-shared/src/account_server/mod.rs @@ -0,0 +1,25 @@ +/// Types related to a client doing an +/// account info request. +pub mod account_info; +/// Types related to a client requesting a account token. +pub mod account_token; +/// The account data as extension for a x509 certificate. +pub mod cert_account_ext; +/// Types related to account server certificates. +pub mod certs; +/// Types related to a client requesting a login +/// token. +pub mod credential_auth_token; +/// Types related to errors generated by the account server. +pub mod errors; +/// Types related to a client doing a login +/// request. +pub mod login; +/// Types related to security of connections. +/// otp = one time password +pub mod otp; +/// Types related to results generated by the account server. +pub mod result; +/// Types related to a client doing an +/// auth request. +pub mod sign; diff --git a/lib/accounts-shared/src/account_server/otp.rs b/lib/accounts-shared/src/account_server/otp.rs new file mode 100644 index 0000000..33f30af --- /dev/null +++ b/lib/accounts-shared/src/account_server/otp.rs @@ -0,0 +1,19 @@ +use rand::Rng; +use serde::{Deserialize, Serialize}; + +/// Represents an one time password, that the +/// account server uses to prevent replay attacks, +/// when clients are authing. +pub type Otp = [u8; 16]; + +/// Generates a new random one time password +pub fn generate_otp() -> Otp { + rand::rngs::OsRng.gen::() +} + +/// The response to a client otp request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OtpResponse { + /// The one time passwords the client can use + pub otps: Vec, +} diff --git a/lib/accounts-shared/src/account_server/result.rs b/lib/accounts-shared/src/account_server/result.rs new file mode 100644 index 0000000..4e73bf4 --- /dev/null +++ b/lib/accounts-shared/src/account_server/result.rs @@ -0,0 +1,4 @@ +use super::errors::AccountServerRequestError; + +/// The result used by all requests to the account server. +pub type AccountServerReqResult = Result>; diff --git a/lib/accounts-shared/src/account_server/sign.rs b/lib/accounts-shared/src/account_server/sign.rs new file mode 100644 index 0000000..0bc69c3 --- /dev/null +++ b/lib/accounts-shared/src/account_server/sign.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +/// The response of an sign request from the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignResponseSuccess { + /// certificate, serialized in der format. + pub cert_der: Vec, +} diff --git a/lib/accounts-shared/src/cert.rs b/lib/accounts-shared/src/cert.rs new file mode 100644 index 0000000..2107b71 --- /dev/null +++ b/lib/accounts-shared/src/cert.rs @@ -0,0 +1,27 @@ +use std::time::Duration; + +use der::Decode; +use ed25519_dalek::{ + pkcs8::{spki::der::pem::LineEnding, EncodePrivateKey}, + SigningKey, +}; +use rcgen::{CertificateParams, KeyPair, PKCS_ED25519}; + +pub use rcgen::CertifiedKey; + +/// Generates a self signed certificate and key-pair as [`CertifiedKey`] +/// from a ed25519 private key. +pub fn generate_self_signed(private_key: &SigningKey) -> anyhow::Result { + let key = private_key.to_pkcs8_pem(LineEnding::LF)?; + let key_pair = KeyPair::from_pkcs8_pem_and_sign_algo(&key, &PKCS_ED25519)?; + let mut cert_params = CertificateParams::new(vec!["localhost".into()])?; + let now = std::time::SystemTime::now(); + cert_params.not_before = (now.checked_sub(Duration::from_secs(60 * 10))) + .unwrap_or(now) + .into(); + cert_params.not_after = (now + Duration::from_secs(60 * 60 * 4)).into(); + let cert = cert_params.self_signed(&key_pair)?; + + // yep, this is stupid, didn't get x509_cert to work with ed25519 keys + Ok(x509_cert::Certificate::from_der(cert.der())?) +} diff --git a/lib/accounts-shared/src/client/account_data.rs b/lib/accounts-shared/src/client/account_data.rs new file mode 100644 index 0000000..193e84a --- /dev/null +++ b/lib/accounts-shared/src/client/account_data.rs @@ -0,0 +1,91 @@ +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::machine_id::{machine_uid, MachineUid}; + +/// This is the account data that should be sent to the server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountDataForServer { + /// The public key for this session. + /// Used to verify the ownership of + /// the key pair on the account server. + pub public_key: VerifyingKey, + /// A unique identifier that is used + /// to verify the user's ownership + /// for the key pair as an additional + /// security enhancement. + pub hw_id: MachineUid, +} + +/// The key pair for the client +/// for the given account. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountDataForClient { + /// A ed25519 private key that is used to to generate + /// a signature to identify the user's session on the account server. + /// __!WARNING!__: Never share this key with anyone. It's only intended + /// to be stored on __one__ of the client's computer. And not even shared between two + /// computers of the same person. + pub private_key: SigningKey, + /// A ed25519 public key, which is sent to the account server and signed + /// to auth the user's session. + pub public_key: VerifyingKey, +} + +/// The result type for [`generate_account_data`]. +/// Contains everything that is required to register a new account +/// or to change a password on client & server. +#[derive(Debug)] +pub struct AccountData { + /// Data that should be send to the server, see [`AccountDataForServer`] + pub for_server: AccountDataForServer, + /// Data that should be kept secret on the client, see [`AccountDataForClient`] + pub for_client: AccountDataForClient, +} + +/// Generates a new key pair based on ed25519 curve. +pub fn key_pair() -> (SigningKey, VerifyingKey) { + // This key-pair is similar to a session token for an account + // The client "registers" a pub-key on the account server, + // which the account server uses to identify the client's + // session private key. + // Additionally the account server generates certificates for + // this public key to proof they correlate to an existing + // account. + let mut rng = rand::rngs::OsRng; + let private_key = SigningKey::generate(&mut rng); + let public_key = private_key.verifying_key(); + (private_key, public_key) +} + +/// This generates new account data from a key pair from an existing key-pair. +/// +/// # Errors +/// Only returns errors if one of the crypto functions +/// failed to execute. +pub fn generate_account_data_from_key_pair( + private_key: SigningKey, + public_key: VerifyingKey, +) -> anyhow::Result { + Ok(AccountData { + for_server: AccountDataForServer { + public_key, + hw_id: machine_uid()?, + }, + for_client: AccountDataForClient { + private_key, + public_key, + }, + }) +} + +/// This generates new account data from a key pair. +/// +/// # Errors +/// Only returns errors if one of the crypto functions +/// failed to execute. +pub fn generate_account_data() -> anyhow::Result { + let (private_key, public_key) = key_pair(); + + generate_account_data_from_key_pair(private_key, public_key) +} diff --git a/lib/accounts-shared/src/client/account_info.rs b/lib/accounts-shared/src/client/account_info.rs new file mode 100644 index 0000000..23f794d --- /dev/null +++ b/lib/accounts-shared/src/client/account_info.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{account_data::AccountDataForServer, machine_id::MachineUid}; + +/// Represents the data required for a account info attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountInfoRequest { + /// The account data related to the account info request. + pub account_data: AccountDataForServer, + /// The timestamp when the sign request was triggered + pub time_stamp: DateTime, + /// The signature for the above time stamp + pub signature: Signature, +} + +/// Generate data for an account info request +pub fn prepare_account_info_request( + hw_id: MachineUid, + key: &SigningKey, + pub_key: VerifyingKey, +) -> AccountInfoRequest { + let time_stamp = chrono::Utc::now(); + let time_str = time_stamp.to_string(); + + let signature = key.sign(time_str.as_bytes()); + + AccountInfoRequest { + account_data: AccountDataForServer { + public_key: pub_key, + hw_id, + }, + signature, + time_stamp, + } +} diff --git a/lib/accounts-shared/src/client/account_token.rs b/lib/accounts-shared/src/client/account_token.rs new file mode 100644 index 0000000..d7b8e0f --- /dev/null +++ b/lib/accounts-shared/src/client/account_token.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use strum::{EnumString, IntoStaticStr}; + +use crate::account_server::otp::Otp; + +/// A token previously sent to email or generated +/// for a steam account, that can be used to perform various +/// actions on an account, e.g. deleting it or removing/revoking +/// all active sessions. +pub type AccountToken = Otp; + +/// The operation for what this account token was generated for. +#[derive(Debug, Serialize, Deserialize, IntoStaticStr, EnumString, Clone, Copy, PartialEq, Eq)] +#[strum(serialize_all = "lowercase")] +pub enum AccountTokenOperation { + /// Logout all sessions at once. + LogoutAll, + /// Link another credential to this account + /// (e.g. email or steam). + LinkCredential, + /// Delete the account. + Delete, +} + +/// A secret key used for a verification process. +pub type SecretKey = [u8; 32]; + +/// A request for an account token by email. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountTokenEmailRequest { + /// The email of the account. + pub email: email_address::EmailAddress, + /// The operation this account token should validate. + pub op: AccountTokenOperation, + /// A secret key that was generated through + /// a verification process (e.g. captchas). + /// It is optional, since these verification + /// processes differ from user to user. + pub secret_key: Option, +} + +/// A request for an account token by steam. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountTokenSteamRequest { + /// The ticket from steam to verify the steamid for + /// the account. + pub steam_ticket: Vec, + /// The operation this account token should validate. + pub op: AccountTokenOperation, + /// A secret key that was generated through + /// a verification process (e.g. captchas). + /// It is optional, since these verification + /// processes differ from user to user. + pub secret_key: Option, +} diff --git a/lib/accounts-shared/src/client/credential_auth_token.rs b/lib/accounts-shared/src/client/credential_auth_token.rs new file mode 100644 index 0000000..416d690 --- /dev/null +++ b/lib/accounts-shared/src/client/credential_auth_token.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; +use strum::{EnumString, IntoStaticStr}; + +/// The operation for what this authorized credential token should do. +#[derive(Debug, Serialize, Deserialize, IntoStaticStr, EnumString, Clone, Copy, PartialEq, Eq)] +#[strum(serialize_all = "lowercase")] +pub enum CredentialAuthTokenOperation { + /// Login using these credentials. + Login, + /// Link the credential to an account + /// (e.g. email or steam). + LinkCredential, + /// Unlink the credential from its account + /// (e.g. email or steam). + /// If the credential is the last bound to + /// the account this operation will fail and + /// [`super::account_token::AccountTokenOperation::Delete`] + /// should be used instead. + UnlinkCredential, +} + +/// A secret key used for a verification process. +pub type SecretKey = [u8; 32]; + +/// A request for a token that is used for the +/// email credential operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredentialAuthTokenEmailRequest { + /// The email of the account to log into. + pub email: email_address::EmailAddress, + /// A secret key that was generated through + /// a verification process (e.g. captchas). + /// It is optional, since these verification + /// processes differ from user to user. + pub secret_key: Option, + /// The operation that this credential authorization + /// should perform. + pub op: CredentialAuthTokenOperation, +} + +/// A request for a token that is used for the +/// steam credential operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredentialAuthTokenSteamRequest { + /// The session token generated on the steam client + /// for the account to log into. + pub steam_ticket: Vec, + /// A secret key that was generated through + /// a verification process (e.g. captchas). + /// It is optional, since these verification + /// processes differ from user to user. + pub secret_key: Option, + /// The operation that this credential authorization + /// should perform. + pub op: CredentialAuthTokenOperation, +} diff --git a/lib/accounts-shared/src/client/delete.rs b/lib/accounts-shared/src/client/delete.rs new file mode 100644 index 0000000..2da5744 --- /dev/null +++ b/lib/accounts-shared/src/client/delete.rs @@ -0,0 +1,23 @@ +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +use super::account_token::AccountToken; + +/// Represents the data required for a delete attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteRequest { + /// An account token that is used to verify that the delete + /// request is valid. + pub account_token: AccountToken, +} + +/// Prepares a delete request for the account server. +pub fn delete(account_token_hex: String) -> anyhow::Result { + let account_token = hex::decode(account_token_hex)?; + + Ok(DeleteRequest { + account_token: account_token + .try_into() + .map_err(|_| anyhow!("Invalid account token."))?, + }) +} diff --git a/lib/accounts-shared/src/client/hash.rs b/lib/accounts-shared/src/client/hash.rs new file mode 100644 index 0000000..cd3decc --- /dev/null +++ b/lib/accounts-shared/src/client/hash.rs @@ -0,0 +1,40 @@ +use anyhow::anyhow; +use argon2::{ + password_hash::{Salt, SaltString}, + Argon2, PasswordHasher, +}; + +/// Generates a hash for the given bytes with the given salt +/// using argon2. +/// +/// # Errors +/// Only throws errors if a crypto function failed unexpected. +pub fn argon2_hash_from_salt(bytes: &[u8], salt: Salt<'_>) -> anyhow::Result<[u8; 32]> { + // Hashed bytes salted as described above + let argon2 = Argon2::default(); + Ok(argon2 + .hash_password(bytes, salt) + .map_err(|err| anyhow!(err))? + .hash + .ok_or_else(|| anyhow!("Hash was not valid"))? + .as_bytes() + .try_into()?) +} + +/// Generates a hash for the given bytes with the given unsecure salt +/// using argon2. +/// Should only be used to hash things that are already secure in itself. +/// +/// # Errors +/// Only throws errors if a crypto function failed unexpected. +pub fn argon2_hash_from_unsecure_salt( + bytes: &[u8], + unsecure_salt: String, +) -> anyhow::Result<[u8; 32]> { + argon2_hash_from_salt( + bytes, + SaltString::encode_b64(unsecure_salt.as_bytes()) + .map_err(|err| anyhow!(err))? + .as_salt(), + ) +} diff --git a/lib/accounts-shared/src/client/link_credential.rs b/lib/accounts-shared/src/client/link_credential.rs new file mode 100644 index 0000000..139b724 --- /dev/null +++ b/lib/accounts-shared/src/client/link_credential.rs @@ -0,0 +1,33 @@ +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +use super::{account_token::AccountToken, login::CredentialAuthToken}; + +/// Represents the data required for a delete attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkCredentialRequest { + /// An account token that is used to verify that the delete + /// request is valid. + pub account_token: AccountToken, + /// Data for the credential specific type, + /// e.g. the email address or steamid. + pub credential_auth_token: CredentialAuthToken, +} + +/// Prepares a link credential request for the account server. +pub fn link_credential( + account_token_hex: String, + credential_auth_token_hex: String, +) -> anyhow::Result { + let account_token = hex::decode(account_token_hex)?; + let credential_auth_token = hex::decode(credential_auth_token_hex)?; + + Ok(LinkCredentialRequest { + account_token: account_token + .try_into() + .map_err(|_| anyhow!("Invalid account token."))?, + credential_auth_token: credential_auth_token + .try_into() + .map_err(|_| anyhow!("Invalid credential auth token."))?, + }) +} diff --git a/lib/accounts-shared/src/client/login.rs b/lib/accounts-shared/src/client/login.rs new file mode 100644 index 0000000..c13dac6 --- /dev/null +++ b/lib/accounts-shared/src/client/login.rs @@ -0,0 +1,72 @@ +use anyhow::anyhow; +use ed25519_dalek::{Signature, Signer}; +use serde::{Deserialize, Serialize}; + +use crate::account_server::otp::Otp; + +use super::account_data::{ + generate_account_data, generate_account_data_from_key_pair, AccountData, AccountDataForClient, + AccountDataForServer, +}; + +/// A credential auth token previously sent to email or generated +/// for a steam login attempt. +pub type CredentialAuthToken = Otp; + +/// Represents the data required for a login attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginRequest { + /// The account data related to the login request. + pub account_data: AccountDataForServer, + /// A credential auth token that was sent by + /// email or generated for a steam based login etc. + pub credential_auth_token: CredentialAuthToken, + /// The signature for the credential auth token, + /// used to make sure the public key corresponds + /// to a valid private key. + pub credential_auth_token_signature: Signature, +} + +fn login_from_account_data( + account_data: AccountData, + credential_auth_token_hex: String, +) -> anyhow::Result<(LoginRequest, AccountDataForClient)> { + let credential_auth_token = hex::decode(credential_auth_token_hex)?; + let signature = account_data + .for_client + .private_key + .sign(&credential_auth_token); + + Ok(( + LoginRequest { + credential_auth_token_signature: signature, + account_data: account_data.for_server, + credential_auth_token: credential_auth_token + .try_into() + .map_err(|_| anyhow!("Invalid credential auth token."))?, + }, + account_data.for_client, + )) +} + +/// Prepares a login request for the account server. +pub fn login_from_client_account_data( + account_data: &AccountDataForClient, + credential_auth_token_hex: String, +) -> anyhow::Result<(LoginRequest, AccountDataForClient)> { + let account_data = generate_account_data_from_key_pair( + account_data.private_key.clone(), + account_data.public_key, + )?; + + login_from_account_data(account_data, credential_auth_token_hex) +} + +/// Prepares a login request for the account server. +pub fn login( + credential_auth_token_hex: String, +) -> anyhow::Result<(LoginRequest, AccountDataForClient)> { + let account_data = generate_account_data()?; + + login_from_account_data(account_data, credential_auth_token_hex) +} diff --git a/lib/accounts-shared/src/client/logout.rs b/lib/accounts-shared/src/client/logout.rs new file mode 100644 index 0000000..2ca2d11 --- /dev/null +++ b/lib/accounts-shared/src/client/logout.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{account_data::AccountDataForServer, machine_id::MachineUid}; + +/// Represents the data required for a logout attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogoutRequest { + /// The account data related to the logout request. + pub account_data: AccountDataForServer, + /// The timestamp when the sign request was triggered + pub time_stamp: DateTime, + /// The signature for the above time stamp + pub signature: Signature, +} + +/// Generate data for an logout request +pub fn prepare_logout_request( + hw_id: MachineUid, + key: &SigningKey, + pub_key: VerifyingKey, +) -> LogoutRequest { + let time_stamp = chrono::Utc::now(); + let time_str = time_stamp.to_string(); + + let signature = key.sign(time_str.as_bytes()); + + LogoutRequest { + account_data: AccountDataForServer { + public_key: pub_key, + hw_id, + }, + signature, + time_stamp, + } +} diff --git a/lib/accounts-shared/src/client/logout_all.rs b/lib/accounts-shared/src/client/logout_all.rs new file mode 100644 index 0000000..ad4f111 --- /dev/null +++ b/lib/accounts-shared/src/client/logout_all.rs @@ -0,0 +1,64 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use ecdsa::signature::Signer; +use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{ + account_data::AccountDataForServer, account_token::AccountToken, machine_id::MachineUid, +}; + +/// Represents a session that is ignored +/// during a logout all attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IgnoreSession { + /// The account data required to verify the session on the account server. + pub account_data: AccountDataForServer, + /// The timestamp when the logout request was triggered + pub time_stamp: DateTime, + /// The signature for the above time stamp + pub signature: Signature, +} + +/// Represents the data required for a logout all attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogoutAllRequest { + /// An account token that is used to verify that the delete + /// request is valid. + pub account_token: AccountToken, + + /// Optionally a session can be ignored during logout. + /// So this logout all is basically a logout all others. + pub ignore_session: Option, +} + +/// Prepares a logout all request for the account server. +pub fn logout_all( + account_token_hex: String, + + hw_id: MachineUid, + key: &SigningKey, + pub_key: VerifyingKey, +) -> anyhow::Result { + let account_token = hex::decode(account_token_hex)?; + + let time_stamp = chrono::Utc::now(); + let time_str = time_stamp.to_string(); + + let signature = key.sign(time_str.as_bytes()); + + Ok(LogoutAllRequest { + account_token: account_token + .try_into() + .map_err(|_| anyhow!("Invalid account token."))?, + + ignore_session: Some(IgnoreSession { + account_data: AccountDataForServer { + public_key: pub_key, + hw_id, + }, + signature, + time_stamp, + }), + }) +} diff --git a/lib/accounts-shared/src/client/machine_id.rs b/lib/accounts-shared/src/client/machine_id.rs new file mode 100644 index 0000000..04522f2 --- /dev/null +++ b/lib/accounts-shared/src/client/machine_id.rs @@ -0,0 +1,21 @@ +use crate::client::hash::argon2_hash_from_unsecure_salt; + +/// A 32-byte unique id per machine. +/// On unsupported systems this creates a default id. +pub type MachineUid = [u8; 32]; + +/// Generates a [`MachineUid`]. +/// On unsupported systems this creates a default id. +pub fn machine_uid() -> anyhow::Result { + #[cfg(not(target_os = "android"))] + { + argon2_hash_from_unsecure_salt( + ::machine_uid::get() + .map_err(|err| anyhow::anyhow!(err.to_string()))? + .as_bytes(), + "ddnet-hw-id".into(), + ) + } + #[cfg(target_os = "android")] + argon2_hash_from_unsecure_salt(&::default(), "ddnet-hw-id".into()) +} diff --git a/lib/accounts-shared/src/client/mod.rs b/lib/accounts-shared/src/client/mod.rs new file mode 100644 index 0000000..f5e77a0 --- /dev/null +++ b/lib/accounts-shared/src/client/mod.rs @@ -0,0 +1,39 @@ +/// All data that represents an account +/// for the client and account server. +/// This account is used to identify +/// uniquely on game-servers. +pub mod account_data; +/// Data types and operations related to prepering +/// an account info request. +pub mod account_info; +/// A data type that is used for various account related operations. +pub mod account_token; +/// Data types and operations related to getting +/// a token for credential operation. +pub mod credential_auth_token; +/// Data types and operations related to prepering +/// a delete request. +pub mod delete; +/// Create hashes using [`argon2`]. +pub mod hash; +/// Data types and operations related to prepering +/// a link credential request. +pub mod link_credential; +/// Data types and operations related to prepering +/// a login request. +pub mod login; +/// Data types and operations related to prepering +/// a logout request. +pub mod logout; +/// Data types and operations related to prepering +/// a logout all request. +pub mod logout_all; +/// Get a unique identifier per machine. +/// On unsupported systems this creates a default id. +pub mod machine_id; +/// Data types and operations that the client uses +/// when an auth to the account server is issued. +pub mod sign; +/// Data types and operations related to prepering +/// a unlink credential request. +pub mod unlink_credential; diff --git a/lib/accounts-shared/src/client/sign.rs b/lib/accounts-shared/src/client/sign.rs new file mode 100644 index 0000000..55d1841 --- /dev/null +++ b/lib/accounts-shared/src/client/sign.rs @@ -0,0 +1,38 @@ +use chrono::{DateTime, Utc}; +use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{account_data::AccountDataForServer, machine_id::MachineUid}; + +/// Represents an auth request the client +/// sends to the account server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignRequest { + /// The account data required to verify the user on the account server. + pub account_data: AccountDataForServer, + /// The timestamp when the sign request was triggered + pub time_stamp: DateTime, + /// The signature for the above time stamp + pub signature: Signature, +} + +/// Generate data for an sign request +pub fn prepare_sign_request( + hw_id: MachineUid, + key: &SigningKey, + pub_key: VerifyingKey, +) -> SignRequest { + let time_stamp = chrono::Utc::now(); + let time_str = time_stamp.to_string(); + + let signature = key.sign(time_str.as_bytes()); + + SignRequest { + account_data: AccountDataForServer { + public_key: pub_key, + hw_id, + }, + signature, + time_stamp, + } +} diff --git a/lib/accounts-shared/src/client/unlink_credential.rs b/lib/accounts-shared/src/client/unlink_credential.rs new file mode 100644 index 0000000..22e6d24 --- /dev/null +++ b/lib/accounts-shared/src/client/unlink_credential.rs @@ -0,0 +1,25 @@ +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +use super::login::CredentialAuthToken; + +/// Represents the data required for a delete attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnlinkCredentialRequest { + /// Data for the credential specific type, + /// e.g. the email address or steamid. + pub credential_auth_token: CredentialAuthToken, +} + +/// Prepares an unlink credential request for the account server. +pub fn unlink_credential( + credential_auth_token_hex: String, +) -> anyhow::Result { + let credential_auth_token = hex::decode(credential_auth_token_hex)?; + + Ok(UnlinkCredentialRequest { + credential_auth_token: credential_auth_token + .try_into() + .map_err(|_| anyhow!("Invalid credential auth token."))?, + }) +} diff --git a/lib/accounts-shared/src/game_server/mod.rs b/lib/accounts-shared/src/game_server/mod.rs new file mode 100644 index 0000000..27b62b8 --- /dev/null +++ b/lib/accounts-shared/src/game_server/mod.rs @@ -0,0 +1,2 @@ +/// Uniquely identify the user. +pub mod user_id; diff --git a/lib/accounts-shared/src/game_server/user_id.rs b/lib/accounts-shared/src/game_server/user_id.rs new file mode 100644 index 0000000..6e8fb08 --- /dev/null +++ b/lib/accounts-shared/src/game_server/user_id.rs @@ -0,0 +1,64 @@ +use accounts_types::account_id::AccountId; +use der::{Decode, Encode}; +use ed25519_dalek::Verifier; +use p256::ecdsa::Signature; +pub use p256::ecdsa::VerifyingKey; +use serde::{Deserialize, Serialize}; + +use crate::account_server::cert_account_ext::AccountCertExt; + +/// A type that represents an user id +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserId { + /// The optional account id. + /// If this is `Some` the game server is garantueed + /// that the user has the account to this account id. + pub account_id: Option, + /// As fallback if no account id was given, + /// the public key (hash/fingerprint) is used to identify the user. + pub public_key: [u8; 32], +} + +/// Get the user id from a certificate send by a client. +/// +/// This function pre-assumes that the certificate is a valid x509 certificate +/// and contains a subject's public key info that can be converted to a +/// fingerprint. +/// +/// If `account_server_public_key` is `None`, then the `account_id` field in the result +/// is guaranteed to be `None`. +/// +/// # Panics +/// Panics, if the cert is not a valid x509 certificate. +/// This should already be checked in the TLS handshake (or similar). +pub fn user_id_from_cert(account_server_public_key: &[VerifyingKey], cert_der: Vec) -> UserId { + let mut account_id = None; + + let Ok(cert) = x509_cert::Certificate::from_der(&cert_der) else { + panic!("not a valid x509 certificate.") + }; + let public_key = cert + .tbs_certificate + .subject_public_key_info + .fingerprint_bytes() + .unwrap_or_default(); + + if let Ok(der) = cert.tbs_certificate.to_der() { + let sig_res = Signature::from_der(cert.signature.raw_bytes()); + if let Ok(signature) = sig_res { + let verify_res = account_server_public_key + .iter() + .any(|key| key.verify(&der, &signature).is_ok()); + if verify_res { + if let Ok(Some((_, account_data))) = cert.tbs_certificate.get::() { + account_id = Some(account_data.data.account_id); + } + } + } + } + + UserId { + account_id, + public_key, + } +} diff --git a/lib/accounts-shared/src/lib.rs b/lib/accounts-shared/src/lib.rs new file mode 100644 index 0000000..ec9329e --- /dev/null +++ b/lib/accounts-shared/src/lib.rs @@ -0,0 +1,21 @@ +//! This crate contains everything that is +//! required to do account related operations. +//! That includes all operations on the server +//! aswell as on the client, aswell as the account +//! server itself. +//! This crate is not intended for creating UI, +//! any game logic or the communication. + +#![deny(missing_docs)] +#![deny(warnings)] +#![deny(clippy::nursery)] +#![deny(clippy::all)] + +/// Everything account related on the account server +pub mod account_server; +/// Everything related to creating certificates +pub mod cert; +/// Everything account related for clients +pub mod client; +/// Everything account related for the game server +pub mod game_server; diff --git a/lib/accounts-types/Cargo.toml b/lib/accounts-types/Cargo.toml new file mode 100644 index 0000000..0a7e356 --- /dev/null +++ b/lib/accounts-types/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "accounts-types" +version = "0.1.0" +edition = "2021" +authors = ["Jupeyy"] +license = "MIT OR Apache-2.0" +description = "Minimal dependency account related types." + +[dependencies] diff --git a/lib/accounts-types/src/account_id.rs b/lib/accounts-types/src/account_id.rs new file mode 100644 index 0000000..0e55bf3 --- /dev/null +++ b/lib/accounts-types/src/account_id.rs @@ -0,0 +1,3 @@ +/// This is the id that refers to an account +/// on the account server. +pub type AccountId = i64; diff --git a/lib/accounts-types/src/lib.rs b/lib/accounts-types/src/lib.rs new file mode 100644 index 0000000..13e1564 --- /dev/null +++ b/lib/accounts-types/src/lib.rs @@ -0,0 +1,11 @@ +//! This crate contains custom types used for the account system. +//! It should generally not depend on crates that cannot be compiled +//! to all rust targets (e.g. WASM). + +#![deny(missing_docs)] +#![deny(warnings)] +#![deny(clippy::nursery)] +#![deny(clippy::all)] + +/// Types related to an account on the account server +pub mod account_id; diff --git a/lib/client-http-fs/Cargo.toml b/lib/client-http-fs/Cargo.toml new file mode 100644 index 0000000..2b2cd54 --- /dev/null +++ b/lib/client-http-fs/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "client-http-fs" +version = "0.1.0" +edition = "2021" +authors = ["Jupeyy"] +license = "MIT OR Apache-2.0" +description = "The base client implementation for accounts, assuming no HTTP client." + +[dependencies] +accounts-shared = { version = "0.1.0", path = "../accounts-shared" } +accounts-types = { version = "0.1.0", path = "../accounts-types" } +account-client = { version = "0.1.0", path = "../account-client" } + +anyhow = { version = "1.0.86", features = ["backtrace"] } +parking_lot = "0.12.3" +async-trait = "0.1.81" +url = { version = "2.5.2", features = ["serde"] } +tokio = { version = "1.39.3", features = ["rt-multi-thread", "sync", "fs", "time", "macros"] } +serde = { version = "1.0.208", features = ["derive"] } +serde_json = "1.0.125" +email_address = { version = "0.2.9", features = ["serde"] } +tempfile = "3.12.0" +x509-cert = { version = "0.2.5" } +either = "1.13.0" +chrono = { version = "0.4.38", features = ["serde"] } diff --git a/lib/client-http-fs/src/cert_downloader.rs b/lib/client-http-fs/src/cert_downloader.rs new file mode 100644 index 0000000..37a1baa --- /dev/null +++ b/lib/client-http-fs/src/cert_downloader.rs @@ -0,0 +1,132 @@ +use std::{ + sync::{Arc, RwLock}, + time::{Duration, SystemTime}, +}; + +use account_client::certs::certs_to_pub_keys; +use accounts_shared::game_server::user_id::VerifyingKey; +use anyhow::anyhow; +use tokio::time::Instant; +use x509_cert::der::{Decode, Encode}; + +use crate::client::ClientHttpTokioFs; + +/// Helper to download the latest public certificates +/// of the account server(s). +/// +/// Automatically redownloads certificates if +/// the current ones are about to expire. +#[derive(Debug)] +pub struct CertsDownloader { + client: Arc, + account_server_public_keys: RwLock>>, + cur_certs: RwLock>, +} + +impl CertsDownloader { + pub async fn new(client: Arc) -> anyhow::Result> { + // try to read the key from disk + let certs_file = client + .fs + .read("account_server_certs.json".as_ref()) + .await + .map_err(|err| anyhow!(err)) + .and_then(|cert_json| { + serde_json::from_slice::>>(&cert_json) + .map_err(|err| anyhow!(err)) + .and_then(|certs_der| { + certs_der + .into_iter() + .map(|cert_der| { + x509_cert::Certificate::from_der(&cert_der) + .map_err(|err| anyhow!(err)) + }) + .collect::>>() + }) + }); + + match certs_file { + Ok(certs_file) => Ok(Arc::new(Self { + client, + account_server_public_keys: RwLock::new(Arc::new(certs_to_pub_keys(&certs_file))), + cur_certs: RwLock::new(certs_file), + })), + Err(_) => { + // try to download latest cert instead + let certs = account_client::certs::download_certs(client.as_ref()).await?; + + let _ = client + .fs + .write( + "".as_ref(), + "account_server_certs.json".as_ref(), + serde_json::to_vec( + &certs + .iter() + .map(|cert| cert.to_der().map_err(|err| anyhow!(err))) + .collect::>>()?, + )?, + ) + .await; + + Ok(Arc::new(Self { + account_server_public_keys: RwLock::new(Arc::new(certs_to_pub_keys(&certs))), + client, + cur_certs: RwLock::new(certs), + })) + } + } + } + + /// Returns the duration when the next certificate gets invalid, + /// or `None` if no certificate exists. + /// + /// `now_offset` gives `now` an additional offset to make + /// the calculation more robust against inaccurate sleeps, + /// or system time out of syncs. + /// (Should be around at least 1 day). + pub fn invalid_in(&self, now: SystemTime, now_offset: Duration) -> Option { + self.cur_certs + .read() + .unwrap() + .iter() + .map(|c| { + c.tbs_certificate + .validity + .not_after + .to_system_time() + .duration_since(now + now_offset) + .unwrap_or(Duration::ZERO) + }) + .min() + } + + pub async fn download_certs(&self) { + if let Ok(certs) = account_client::certs::download_certs(self.client.as_ref()).await { + let new_account_server_public_keys = certs_to_pub_keys(&certs); + *self.cur_certs.write().unwrap() = certs; + + *self.account_server_public_keys.write().unwrap() = + Arc::new(new_account_server_public_keys); + } + } + + pub async fn download_task(&self) -> ! { + loop { + let invalid_in = + self.invalid_in(SystemTime::now(), Duration::from_secs(7 * 24 * 60 * 60)); + + // either if first cert is about to invalidate or when one week passed + let one_week = Duration::from_secs(7 * 24 * 60 * 60); + let duration_offset = invalid_in.unwrap_or(one_week).min(one_week); + + tokio::time::sleep_until(Instant::now() + duration_offset).await; + + self.download_certs().await; + } + } + + pub fn public_keys(&self) -> Arc> { + self.account_server_public_keys.read().unwrap().clone() + } +} diff --git a/lib/client-http-fs/src/client.rs b/lib/client-http-fs/src/client.rs new file mode 100644 index 0000000..478ce57 --- /dev/null +++ b/lib/client-http-fs/src/client.rs @@ -0,0 +1,344 @@ +use std::{ + sync::{atomic::AtomicUsize, Arc}, + time::Duration, +}; + +use account_client::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, +}; +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +use crate::{fs::Fs, http::Http}; + +#[derive(Debug, Serialize, Deserialize)] +struct FastestHttp { + index: u64, + valid_until: chrono::DateTime, +} + +/// An extension to the client for deleting the current +/// directory. +#[async_trait::async_trait] +pub trait DeleteAccountExt: Sync + Send { + async fn remove_account(&self) -> anyhow::Result<(), FsLikeError>; +} + +#[derive(Debug)] +pub struct ClientHttpTokioFs { + pub http: Vec>, + pub cur_http: AtomicUsize, + pub fs: Fs, +} + +impl ClientHttpTokioFs { + async fn post_json_impl( + &self, + http_index: usize, + url: &str, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + let http = &self.http[http_index]; + http.post_json( + http.base_url() + .join(url) + .map_err(|err| HttpLikeError::Other(err.into()))?, + data, + ) + .await + } + + async fn backup_post_json( + &self, + except_http_index: usize, + url: &str, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + for i in 0..self.http.len() { + if i == except_http_index { + continue; + } + match self.post_json_impl(i, url, data.clone()).await { + Ok(res) => { + self.cur_http.store(i, std::sync::atomic::Ordering::Relaxed); + return Ok(res); + } + Err(err) => match err { + HttpLikeError::Request | HttpLikeError::Status(_) => { + // try another http instance + } + HttpLikeError::Other(err) => { + return Err(HttpLikeError::Other(err)); + } + }, + } + } + Err(HttpLikeError::Request) + } + + pub async fn post_json( + &self, + url: &str, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + let http_index = self.cur_http.load(std::sync::atomic::Ordering::Relaxed); + match self.post_json_impl(http_index, url, data.clone()).await { + Ok(res) => Ok(res), + Err(err) => match err { + HttpLikeError::Request | HttpLikeError::Status(_) => { + match self.backup_post_json(http_index, url, data).await { + Ok(data) => Ok(data), + Err(_) => Err(err), + } + } + HttpLikeError::Other(err) => Err(HttpLikeError::Other(err)), + }, + } + } + + async fn get_json_http( + http: &Arc, + url: &str, + ) -> anyhow::Result, HttpLikeError> { + http.get( + http.base_url() + .join(url) + .map_err(|err| HttpLikeError::Other(err.into()))?, + ) + .await + } + + async fn get_json_impl( + &self, + http_index: usize, + url: &str, + ) -> anyhow::Result, HttpLikeError> { + let http = &self.http[http_index]; + Self::get_json_http(http, url).await + } + + async fn backup_get_json( + &self, + except_http_index: usize, + url: &str, + ) -> anyhow::Result, HttpLikeError> { + for i in 0..self.http.len() { + if i == except_http_index { + continue; + } + match self.get_json_impl(i, url).await { + Ok(res) => { + self.cur_http.store(i, std::sync::atomic::Ordering::Relaxed); + return Ok(res); + } + Err(err) => match err { + HttpLikeError::Request | HttpLikeError::Status(_) => { + // try another http instance + } + HttpLikeError::Other(err) => { + return Err(HttpLikeError::Other(err)); + } + }, + } + } + Err(HttpLikeError::Request) + } + + pub async fn get_json(&self, url: &str) -> anyhow::Result, HttpLikeError> { + let http_index = self.cur_http.load(std::sync::atomic::Ordering::Relaxed); + match self.get_json_impl(http_index, url).await { + Ok(res) => Ok(res), + Err(err) => match err { + HttpLikeError::Request | HttpLikeError::Status(_) => { + match self.backup_get_json(http_index, url).await { + Ok(data) => Ok(data), + Err(_) => Err(err), + } + } + HttpLikeError::Other(err) => Err(HttpLikeError::Other(err)), + }, + } + } + + async fn evalulate_fastest_http(http: &[Arc]) -> usize { + let mut handles: Vec<_> = Default::default(); + for (i, http) in http.iter().enumerate() { + let http = http.clone(); + handles.push(( + tokio::spawn(async move { + let i = std::time::Instant::now(); + match Self::get_json_http(&http, "/ping").await { + Ok(_) => Some(std::time::Instant::now().saturating_duration_since(i)), + Err(_) => None, + } + }), + i, + )); + } + let mut results: Vec<_> = Default::default(); + for (task, i) in handles { + if let Ok(Some(time)) = task.await { + results.push((time, i)); + } + } + results + .into_iter() + .min_by_key(|(time, _)| *time) + .map(|(_, index)| index) + .unwrap_or_default() + } + + pub async fn get_fastest_http(fs: &Fs, http: &[Arc]) -> usize { + let eval_fastest = || { + Box::pin(async { + let index = Self::evalulate_fastest_http(http).await; + let _ = fs + .write( + "".as_ref(), + "fastest_http.json".as_ref(), + serde_json::to_vec(&FastestHttp { + index: index as u64, + valid_until: chrono::Utc::now() + + Duration::from_secs(60 * 60 * 24 * 30), + }) + .unwrap(), + ) + .await; + index + }) + }; + match fs + .read("fastest_http.json".as_ref()) + .await + .map_err(|err| anyhow!(err)) + .and_then(|json| { + serde_json::from_slice::(&json).map_err(|err| anyhow!(err)) + }) + .and_then(|fastest_http| { + if chrono::Utc::now() < fastest_http.valid_until { + Ok(fastest_http) + } else { + Err(anyhow!("fastest_http not valid any more.")) + } + }) { + Ok(fastest_http) => { + if (fastest_http.index as usize) < http.len() { + fastest_http.index as usize + } else { + eval_fastest().await + } + } + Err(_) => eval_fastest().await, + } + } +} + +#[async_trait::async_trait] +impl Io for ClientHttpTokioFs { + async fn request_credential_auth_email_token( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/token/email", data).await + } + async fn request_credential_auth_steam_token( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/token/steam", data).await + } + async fn request_credential_auth_email_token_with_secret_key( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/token/email-secret", data).await + } + async fn request_credential_auth_steam_token_with_secret_key( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/token/steam-secret", data).await + } + async fn request_login(&self, data: Vec) -> anyhow::Result, HttpLikeError> { + self.post_json("/login", data).await + } + async fn request_logout(&self, data: Vec) -> anyhow::Result, HttpLikeError> { + self.post_json("/logout", data).await + } + async fn request_sign(&self, data: Vec) -> anyhow::Result, HttpLikeError> { + self.post_json("/sign", data).await + } + async fn request_account_token_email( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/account-token/email", data).await + } + async fn request_account_token_email_secret( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/account-token/email-secret", data).await + } + async fn request_account_token_steam( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/account-token/steam", data).await + } + async fn request_account_token_steam_secret( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/account-token/steam-secret", data).await + } + async fn request_logout_all(&self, data: Vec) -> anyhow::Result, HttpLikeError> { + self.post_json("/logout-all", data).await + } + async fn request_delete_account( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/delete", data).await + } + async fn request_link_credential( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/link-credential", data).await + } + async fn request_unlink_credential( + &self, + data: Vec, + ) -> anyhow::Result, HttpLikeError> { + self.post_json("/unlink-credential", data).await + } + async fn request_account_info(&self, data: Vec) -> anyhow::Result, HttpLikeError> { + self.post_json("/account-info", data).await + } + async fn download_account_server_certificates(&self) -> anyhow::Result, HttpLikeError> { + self.get_json("/certs").await + } + async fn write_serialized_session_key_pair( + &self, + file: Vec, + ) -> anyhow::Result<(), FsLikeError> { + self.fs + .write("".as_ref(), "account.key".as_ref(), file) + .await + } + async fn read_serialized_session_key_pair(&self) -> anyhow::Result, FsLikeError> { + self.fs.read("account.key".as_ref()).await + } + async fn remove_serialized_session_key_pair(&self) -> anyhow::Result<(), FsLikeError> { + self.fs.remove("account.key".as_ref()).await + } +} + +#[async_trait::async_trait] +impl DeleteAccountExt for ClientHttpTokioFs { + async fn remove_account(&self) -> anyhow::Result<(), FsLikeError> { + self.fs.delete().await + } +} diff --git a/lib/client-http-fs/src/fs.rs b/lib/client-http-fs/src/fs.rs new file mode 100644 index 0000000..e348dd5 --- /dev/null +++ b/lib/client-http-fs/src/fs.rs @@ -0,0 +1,61 @@ +use std::{ + ffi::OsStr, + io::Write, + path::{Path, PathBuf}, +}; + +use account_client::errors::FsLikeError; + +#[derive(Debug, Clone)] +pub struct Fs { + pub secure_path: PathBuf, +} + +impl Fs { + async fn create_dirs_impl(path: impl AsRef) -> anyhow::Result<(), FsLikeError> { + Ok(tokio::fs::create_dir_all(path).await?) + } + + pub async fn new(secure_path: PathBuf) -> anyhow::Result { + Self::create_dirs_impl(&secure_path).await?; + Ok(Self { secure_path }) + } + + pub async fn delete(&self) -> anyhow::Result<(), FsLikeError> { + tokio::fs::remove_dir(&self.secure_path).await?; + Ok(()) + } + + pub async fn create_dirs(&self, path: &Path) -> anyhow::Result<(), FsLikeError> { + Self::create_dirs_impl(self.secure_path.join(path)).await + } + + pub async fn write( + &self, + path: &Path, + name: &OsStr, + file: Vec, + ) -> anyhow::Result<(), FsLikeError> { + let path = self.secure_path.join(path); + let path_thread = path.clone(); + let tmp_file = tokio::task::spawn_blocking(move || { + let mut tmp_file = tempfile::NamedTempFile::new_in(&path_thread)?; + tmp_file.write_all(&file)?; + tmp_file.flush()?; + Ok::<_, std::io::Error>(tmp_file) + }) + .await + .map_err(|err| FsLikeError::Other(err.into()))??; + let (_, tmp_path) = tmp_file.keep().map_err(|err| FsLikeError::Fs(err.error))?; + tokio::fs::rename(tmp_path, path.join(name)).await?; + Ok(()) + } + + pub async fn read(&self, path: &Path) -> anyhow::Result, FsLikeError> { + Ok(tokio::fs::read(self.secure_path.join(path)).await?) + } + + pub async fn remove(&self, path: &Path) -> anyhow::Result<(), FsLikeError> { + Ok(tokio::fs::remove_file(self.secure_path.join(path)).await?) + } +} diff --git a/lib/client-http-fs/src/http.rs b/lib/client-http-fs/src/http.rs new file mode 100644 index 0000000..c9942e9 --- /dev/null +++ b/lib/client-http-fs/src/http.rs @@ -0,0 +1,15 @@ +use std::fmt::Debug; + +use account_client::errors::HttpLikeError; +use async_trait::async_trait; +use url::Url; + +#[async_trait] +pub trait Http: Debug + Sync + Send { + fn new(base_url: Url) -> Self + where + Self: Sized; + async fn post_json(&self, url: Url, data: Vec) -> anyhow::Result, HttpLikeError>; + async fn get(&self, url: Url) -> anyhow::Result, HttpLikeError>; + fn base_url(&self) -> Url; +} diff --git a/lib/client-http-fs/src/lib.rs b/lib/client-http-fs/src/lib.rs new file mode 100644 index 0000000..2e94039 --- /dev/null +++ b/lib/client-http-fs/src/lib.rs @@ -0,0 +1,5 @@ +pub mod cert_downloader; +pub mod client; +pub mod fs; +pub mod http; +pub mod profiles; diff --git a/lib/client-http-fs/src/profiles.rs b/lib/client-http-fs/src/profiles.rs new file mode 100644 index 0000000..129d030 --- /dev/null +++ b/lib/client-http-fs/src/profiles.rs @@ -0,0 +1,874 @@ +use std::{ + collections::HashMap, + fmt::Debug, + future::Future, + ops::Deref, + path::PathBuf, + pin::Pin, + sync::Arc, + time::{Duration, SystemTime}, +}; + +pub use account_client::{ + account_token::AccountTokenResult, credential_auth_token::CredentialAuthTokenResult, +}; +use account_client::{interface::Io, sign::SignResult}; +use accounts_shared::{ + account_server::account_info::AccountInfoResponse, + cert::generate_self_signed, + client::{ + account_data::{key_pair, AccountDataForClient}, + account_token::AccountTokenOperation, + credential_auth_token::CredentialAuthTokenOperation, + }, +}; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use either::Either; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use x509_cert::der::Decode; + +pub use x509_cert::Certificate; + +use crate::{client::DeleteAccountExt, fs::Fs}; + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ProfileData { + pub name: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct ProfilesState { + pub profiles: HashMap, + pub cur_profile: String, +} + +impl ProfilesState { + async fn load_or_default(fs: &Fs) -> Self { + fs.read("profiles.json".as_ref()) + .await + .map_err(|err| anyhow!(err)) + .and_then(|file| serde_json::from_slice(&file).map_err(|err| anyhow!(err))) + .unwrap_or_default() + } + + async fn save(&self, fs: &Fs) -> anyhow::Result<()> { + let file_content = serde_json::to_vec_pretty(self)?; + fs.write("".as_ref(), "profiles.json".as_ref(), file_content) + .await?; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct ProfileCertAndKeys { + pub cert: Certificate, + pub key_pair: AccountDataForClient, + pub valid_duration: Duration, +} + +#[derive(Debug, Default, Clone)] +pub enum ProfileCert { + #[default] + None, + Fetching(Arc), + CertAndKeys(Box), + CertAndKeysAndFetch { + cert_and_keys: Box, + notifier: Arc, + }, +} + +#[derive(Debug)] +pub struct ActiveProfile { + client: Arc, + cur_cert: Arc>, + + profile_data: ProfileData, +} + +#[derive(Debug, Default)] +pub struct ActiveProfiles { + profiles: HashMap>, + cur_profile: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AccountlessKeysAndValidy { + account_data: AccountDataForClient, + valid_until: chrono::DateTime, +} +// 3 months validy +fn accountless_validy_range() -> Duration { + Duration::from_secs(60 * 60 * 24 * 30 * 3) +} +const ACCOUNTLESS_KEYS_FILE: &str = "accountless_keys_and_cert.json"; + +/// Helper for multiple account profiles. +#[derive(Debug)] +pub struct Profiles< + C: Io + DeleteAccountExt + Debug, + F: Deref< + Target = dyn Fn( + PathBuf, + ) + -> Pin> + Sync + Send>>, + > + Debug + + Sync + + Send, +> { + profiles: Arc>>, + factory: Arc, + secure_base_path: Arc, + fs: Fs, +} + +impl< + C: Io + DeleteAccountExt + Debug + 'static, + F: Deref< + Target = dyn Fn( + PathBuf, + ) + -> Pin> + Sync + Send>>, + > + Debug + + Sync + + Send, + > Profiles +{ + fn to_profile_states(profiles: &ActiveProfiles) -> ProfilesState { + let mut res = ProfilesState::default(); + + res.profiles.extend( + profiles + .profiles + .iter() + .map(|(key, val)| (key.clone(), val.profile_data.clone())), + ); + res.cur_profile.clone_from(&profiles.cur_profile); + + res + } + + fn account_id_to_path(account_id: AccountId) -> String { + format!("acc_{}", account_id) + } + + pub fn new(loading: ProfilesLoading) -> Self { + Self { + profiles: Arc::new(loading.profiles), + factory: loading.factory, + secure_base_path: Arc::new(loading.secure_base_path), + fs: loading.fs, + } + } + + /// generate a token for a new email credential auth attempt. + pub async fn credential_auth_email_token( + &self, + email: email_address::EmailAddress, + op: CredentialAuthTokenOperation, + secret_key_hex: Option, + ) -> anyhow::Result<(), CredentialAuthTokenResult> { + let path = self.secure_base_path.join("acc_prepare"); + let account_client = Arc::new( + (self.factory)(path) + .await + .map_err(CredentialAuthTokenResult::Other)?, + ); + + account_client::credential_auth_token::credential_auth_token_email( + email, + op, + secret_key_hex, + account_client.as_ref(), + ) + .await?; + + Ok(()) + } + + /// generate a token for a new steam credential auth attempt. + pub async fn credential_auth_steam_token( + &self, + steam_ticket: Vec, + op: CredentialAuthTokenOperation, + secret_key_hex: Option, + ) -> anyhow::Result { + let path = self.secure_base_path.join("acc_prepare"); + let account_client = Arc::new( + (self.factory)(path) + .await + .map_err(CredentialAuthTokenResult::Other)?, + ); + + account_client::credential_auth_token::credential_auth_token_steam( + steam_ticket, + op, + secret_key_hex, + account_client.as_ref(), + ) + .await + } + + /// generate a token for a new email account operation attempt. + pub async fn account_email_token( + &self, + email: email_address::EmailAddress, + op: AccountTokenOperation, + secret_key_hex: Option, + ) -> anyhow::Result<(), AccountTokenResult> { + let path = self.secure_base_path.join("acc_prepare"); + let account_client = Arc::new( + (self.factory)(path) + .await + .map_err(AccountTokenResult::Other)?, + ); + + account_client::account_token::account_token_email( + email, + op, + secret_key_hex, + account_client.as_ref(), + ) + .await?; + + Ok(()) + } + + /// generate a token for a new steam account operation attempt. + pub async fn account_steam_token( + &self, + steam_ticket: Vec, + op: AccountTokenOperation, + secret_key_hex: Option, + ) -> anyhow::Result { + let path = self.secure_base_path.join("acc_prepare"); + let account_client = Arc::new( + (self.factory)(path) + .await + .map_err(AccountTokenResult::Other)?, + ); + + account_client::account_token::account_token_steam( + steam_ticket, + op, + secret_key_hex, + account_client.as_ref(), + ) + .await + } + + async fn read_accountless_keys(fs: &Fs) -> anyhow::Result { + fs.read(ACCOUNTLESS_KEYS_FILE.as_ref()) + .await + .map_err(|err| anyhow!(err)) + .and_then(|file| { + serde_json::from_slice::(&file) + .map_err(|err| anyhow!(err)) + }) + .and_then(|accountless_keys_and_validy| { + let now: chrono::DateTime = std::time::SystemTime::now().into(); + (now.signed_duration_since(accountless_keys_and_validy.valid_until) + < chrono::TimeDelta::new( + accountless_validy_range().as_secs() as i64, + accountless_validy_range().subsec_nanos(), + ) + .unwrap_or(chrono::TimeDelta::max_value())) + .then_some(accountless_keys_and_validy) + .ok_or_else(|| anyhow!("accountless keys too old")) + }) + } + + async fn take_accountless_keys(&self) -> anyhow::Result { + let account_data = Self::read_accountless_keys(&self.fs).await?; + + self.fs.remove(ACCOUNTLESS_KEYS_FILE.as_ref()).await?; + + Ok(account_data.account_data) + } + + async fn login_impl( + &self, + display_name: &str, + credential_auth_token_hex: String, + ) -> anyhow::Result<()> { + let path = self.secure_base_path.join("acc_prepare"); + let account_client = Arc::new((self.factory)(path).await?); + + // first try to "upgrade" the accountless keys to a real account. + let (account_id, login_data_writer) = if let Ok(account_data) = + self.take_accountless_keys().await + { + account_client::login::login_with_account_data( + credential_auth_token_hex, + &account_data, + account_client.as_ref(), + ) + .await? + } else { + account_client::login::login(credential_auth_token_hex, account_client.as_ref()).await? + }; + + let profile_name = Self::account_id_to_path(account_id); + let path = self.secure_base_path.join(&profile_name); + let account_client = Arc::new((self.factory)(path).await?); + + login_data_writer.write(&*account_client).await?; + + let profile = ActiveProfile { + client: account_client, + cur_cert: Default::default(), + profile_data: ProfileData { + name: display_name.to_string(), + }, + }; + + let profiles_state; + { + let mut profiles = self.profiles.lock(); + profiles.profiles.insert(profile_name.to_string(), profile); + profiles.cur_profile = profile_name.to_string(); + profiles_state = Self::to_profile_states(&profiles); + drop(profiles); + } + + profiles_state.save(&self.fs).await?; + + self.signed_cert_and_key_pair().await; + + Ok(()) + } + + /// try to login via credential auth token previously created with e.g. [`Self::credential_auth_email_token`] + pub async fn login_email( + &self, + email: email_address::EmailAddress, + credential_auth_token_hex: String, + ) -> anyhow::Result<()> { + self.login_impl( + &format!("{}'s account", email.local_part()), + credential_auth_token_hex, + ) + .await + } + + /// try to login via credential auth token previously created with e.g. [`Self::login_steam_token`] + pub async fn login_steam( + &self, + steam_user_name: String, + credential_auth_token_hex: String, + ) -> anyhow::Result<()> { + self.login_impl( + &format!("{}'s account", steam_user_name), + credential_auth_token_hex, + ) + .await + } + + /// removes the profile + async fn remove_profile( + profiles: Arc>>, + fs: &Fs, + profile_name: &str, + ) -> anyhow::Result<()> { + let profiles_state; + let removed_profile; + { + let mut profiles = profiles.lock(); + removed_profile = profiles.profiles.remove(profile_name); + if profiles.cur_profile == profile_name { + profiles.cur_profile = profiles.profiles.keys().next().cloned().unwrap_or_default(); + } + profiles_state = Self::to_profile_states(&profiles); + drop(profiles); + } + + profiles_state.save(fs).await?; + + if let Some(profile) = removed_profile { + let _ = profile.client.remove_account().await; + } + + Ok(()) + } + + /// If no account was found, fall back to key-pair that + /// is not account based, but could be upgraded + async fn account_less_cert_and_key_pair( + fs_or_account_data: Either<&Fs, AccountDataForClient>, + err: Option, + ) -> (AccountDataForClient, Certificate, Option) { + match fs_or_account_data { + Either::Left(fs) => { + let (account_data, cert) = if let Ok((account_data, cert)) = + Self::read_accountless_keys(fs) + .await + .and_then(|accountless_keys_and_validy| { + generate_self_signed( + &accountless_keys_and_validy.account_data.private_key, + ) + .map_err(|err| anyhow!(err)) + .map(|cert| (accountless_keys_and_validy.account_data, cert)) + }) { + (account_data, cert) + } else { + let (private_key, public_key) = key_pair(); + + let cert = generate_self_signed(&private_key).unwrap(); + + // save the newely generated cert & account data + let accountless_keys_and_cert = AccountlessKeysAndValidy { + account_data: AccountDataForClient { + private_key, + public_key, + }, + valid_until: (std::time::SystemTime::now() + accountless_validy_range()) + .into(), + }; + + // ignore errors, can't recover anyway + if let Ok(file) = serde_json::to_vec(&accountless_keys_and_cert) { + let _ = fs + .write("".as_ref(), ACCOUNTLESS_KEYS_FILE.as_ref(), file) + .await; + } + + (accountless_keys_and_cert.account_data, cert) + }; + (account_data, cert, err) + } + Either::Right(account_data) => { + let cert = generate_self_signed(&account_data.private_key).unwrap(); + (account_data, cert, err) + } + } + } + + /// Gets a _recently_ signed cerificate from the accounts server + /// and the key pair of the client. + /// If an error occurred a self signed cert & key-pair will still be generated to + /// allow playing at all cost. + /// It's up to the implementation how it wants to inform the user about + /// this error. + pub async fn signed_cert_and_key_pair( + &self, + ) -> (AccountDataForClient, Certificate, Option) { + let mut cur_cert_der = None; + let mut account_client = None; + let mut cur_profile = None; + { + let profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get(&profiles.cur_profile) { + cur_cert_der = Some(profile.cur_cert.clone()); + account_client = Some(profile.client.clone()); + cur_profile = Some(profiles.cur_profile.clone()); + } + drop(profiles); + } + + if let Some(((cur_cert, client), cur_profile)) = + cur_cert_der.zip(account_client).zip(cur_profile) + { + let mut try_fetch = None; + let mut try_wait = None; + { + let mut cert = cur_cert.lock(); + match &*cert { + ProfileCert::None => { + let notifier: Arc = Default::default(); + *cert = ProfileCert::Fetching(notifier.clone()); + try_fetch = Some((notifier, true)); + } + ProfileCert::Fetching(notifier) => { + try_wait = Some(notifier.clone()); + } + ProfileCert::CertAndKeys(cert_and_keys) => { + // check if cert is outdated + let expires_at = cert_and_keys + .cert + .tbs_certificate + .validity + .not_after + .to_system_time(); + // if it is about to expire, fetch again replacing the old ones + if expires_at < SystemTime::now() + Duration::from_secs(60 * 10) { + let notifier: Arc = Default::default(); + *cert = ProfileCert::Fetching(notifier.clone()); + try_fetch = Some((notifier, true)); + } + // else if the cert's lifetime already hit the half, try to fetch, but don't replace the existing one + else if expires_at < SystemTime::now() + cert_and_keys.valid_duration / 2 + { + let notifier: Arc = Default::default(); + *cert = ProfileCert::CertAndKeysAndFetch { + cert_and_keys: cert_and_keys.clone(), + notifier: notifier.clone(), + }; + try_fetch = Some((notifier, false)); + } + } + ProfileCert::CertAndKeysAndFetch { + cert_and_keys, + notifier, + } => { + // if fetching gets urgent, downgrade this to fetch operation + let expires_at = cert_and_keys + .cert + .tbs_certificate + .validity + .not_after + .to_system_time(); + if expires_at < SystemTime::now() + Duration::from_secs(60 * 10) { + let notifier = notifier.clone(); + *cert = ProfileCert::Fetching(notifier.clone()); + try_wait = Some(notifier); + } + // else just ignore + } + } + } + + if let Some(notifier) = try_wait { + notifier.notified().await; + // notify the next one + notifier.notify_one(); + } + + let should_wait = if let Some((notifier, should_wait)) = try_fetch { + let fs = self.fs.clone(); + let profiles = self.profiles.clone(); + let cur_cert = cur_cert.clone(); + let res = tokio::spawn(async move { + let res = match account_client::sign::sign(client.as_ref()).await { + Ok(sign_data) => { + if let Ok(cert) = Certificate::from_der(&sign_data.certificate_der) { + *cur_cert.lock() = + ProfileCert::CertAndKeys(Box::new(ProfileCertAndKeys { + cert: cert.clone(), + key_pair: sign_data.session_key_pair.clone(), + valid_duration: cert + .tbs_certificate + .validity + .not_after + .to_system_time() + .duration_since(SystemTime::now()) + .unwrap_or(Duration::ZERO), + })); + (sign_data.session_key_pair, cert, None) + } else { + Self::account_less_cert_and_key_pair( + Either::Left(&fs), + Some(anyhow!( + "account server did not return a valid certificate, \ + please contact a developer." + )), + ) + .await + } + } + Err(err) => { + *cur_cert.lock() = ProfileCert::None; + // if the error was a file system error + // or session was invalid for other reasons, then remove that profile. + match err { + SignResult::SessionWasInvalid | SignResult::FsLikeError(_) => { + // try to remove that profile + let _ = Self::remove_profile(profiles, &fs, &cur_profile).await; + Self::account_less_cert_and_key_pair( + Either::Left(&fs), + Some(err.into()), + ) + .await + } + SignResult::HttpLikeError { + ref account_data, .. + } + | SignResult::Other { + ref account_data, .. + } => { + // tell the fallback key mechanism to try the account data, + // even if self signed, this can allow a game server + // to recover lost account related data. (But does not require to) + Self::account_less_cert_and_key_pair( + Either::Right(account_data.clone()), + Some(err.into()), + ) + .await + } + } + } + }; + notifier.notify_one(); + res + }); + should_wait.then_some(res) + } else { + None + }; + + // if fetching was urgent, it must wait for the task to complete. + let awaited_task = if let Some(task) = should_wait { + task.await.ok() + } else { + None + }; + + if let Some(res) = awaited_task { + res + } else { + let (ProfileCert::CertAndKeys(cert_and_keys) + | ProfileCert::CertAndKeysAndFetch { cert_and_keys, .. }) = cur_cert.lock().clone() + else { + return Self::account_less_cert_and_key_pair( + Either::Left(&self.fs), + Some(anyhow!("no cert or key found.")), + ) + .await; + }; + let ProfileCertAndKeys { cert, key_pair, .. } = *cert_and_keys; + + (key_pair, cert, None) + } + } else { + Self::account_less_cert_and_key_pair(Either::Left(&self.fs), None).await + } + } + + /// Tries to logout the given profile + pub async fn logout(&self, profile_name: &str) -> anyhow::Result<()> { + let mut account_client = None; + { + let profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get(profile_name) { + account_client = Some(profile.client.clone()); + } + drop(profiles); + } + let Some(account_client) = account_client else { + return Err(anyhow::anyhow!( + "Profile with name {} not found", + profile_name + )); + }; + account_client::logout::logout(&*account_client).await?; + Self::remove_profile(self.profiles.clone(), &self.fs, profile_name).await + } + + /// Tries to logout all session except the current for the given profile + pub async fn logout_all( + &self, + account_token_hex: String, + profile_name: &str, + ) -> anyhow::Result<()> { + let mut account_client = None; + { + let profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get(profile_name) { + account_client = Some(profile.client.clone()); + } + drop(profiles); + } + let Some(account_client) = account_client else { + return Err(anyhow::anyhow!( + "Profile with name {} not found", + profile_name + )); + }; + Ok(account_client::logout_all::logout_all(account_token_hex, &*account_client).await?) + } + + /// Tries to delete the account of the given profile + pub async fn delete( + &self, + account_token_hex: String, + profile_name: &str, + ) -> anyhow::Result<()> { + let mut account_client = None; + { + let profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get(profile_name) { + account_client = Some(profile.client.clone()); + } + drop(profiles); + } + let Some(account_client) = account_client else { + return Err(anyhow::anyhow!( + "Profile with name {} not found", + profile_name + )); + }; + account_client::delete::delete(account_token_hex, &*account_client).await?; + Self::remove_profile(self.profiles.clone(), &self.fs, profile_name).await + } + + /// Tries to link a credential for the given profile + pub async fn link_credential( + &self, + account_token_hex: String, + credential_auth_token_hex: String, + profile_name: &str, + ) -> anyhow::Result<()> { + let mut account_client = None; + { + let profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get(profile_name) { + account_client = Some(profile.client.clone()); + } + drop(profiles); + } + let Some(account_client) = account_client else { + return Err(anyhow::anyhow!( + "Profile with name {} not found", + profile_name + )); + }; + Ok(account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*account_client, + ) + .await?) + } + + /// Tries to unlink a credential for the given profile + pub async fn unlink_credential( + &self, + credential_auth_token_hex: String, + profile_name: &str, + ) -> anyhow::Result<()> { + let mut account_client = None; + { + let profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get(profile_name) { + account_client = Some(profile.client.clone()); + } + drop(profiles); + } + let Some(account_client) = account_client else { + return Err(anyhow::anyhow!( + "Profile with name {} not found", + profile_name + )); + }; + Ok(account_client::unlink_credential::unlink_credential( + credential_auth_token_hex, + &*account_client, + ) + .await?) + } + + /// Tries to fetch the account info for the given profile + pub async fn account_info(&self, profile_name: &str) -> anyhow::Result { + let mut account_client = None; + { + let profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get(profile_name) { + account_client = Some(profile.client.clone()); + } + drop(profiles); + } + let Some(account_client) = account_client else { + return Err(anyhow::anyhow!( + "Profile with name {} not found", + profile_name + )); + }; + Ok(account_client::account_info::account_info(&*account_client).await?) + } + + /// Currently loaded profiles + pub fn profiles(&self) -> (HashMap, String) { + let profiles = self.profiles.lock(); + let profiles = Self::to_profile_states(&profiles); + (profiles.profiles, profiles.cur_profile) + } + + /// Set the current profile to a new one. + /// Silently fails, if the new profile does not exist. + pub async fn set_profile(&self, profile_name: &str) { + let profiles_state; + { + let mut profiles = self.profiles.lock(); + if profiles.profiles.contains_key(profile_name) { + profiles.cur_profile = profile_name.to_string(); + } + profiles_state = Self::to_profile_states(&profiles); + drop(profiles); + } + + let _ = profiles_state.save(&self.fs).await; + } + + /// Set the profile's display name to a new one. + /// Silently fails, if the profile does not exist. + pub async fn set_profile_display_name(&self, profile_name: &str, display_name: String) { + let profiles_state; + { + let mut profiles = self.profiles.lock(); + if let Some(profile) = profiles.profiles.get_mut(profile_name) { + profile.profile_data.name = display_name; + } + profiles_state = Self::to_profile_states(&profiles); + drop(profiles); + } + + let _ = profiles_state.save(&self.fs).await; + } +} + +#[derive(Debug)] +pub struct ProfilesLoading< + C: Io + DeleteAccountExt + Debug, + F: Deref< + Target = dyn Fn( + PathBuf, + ) + -> Pin> + Sync + Send>>, + > + Debug + + Sync + + Send, +> { + pub profiles: parking_lot::Mutex>, + pub factory: Arc, + pub secure_base_path: PathBuf, + fs: Fs, +} + +impl< + C: Io + DeleteAccountExt + Debug, + F: Deref< + Target = dyn Fn( + PathBuf, + ) + -> Pin> + Sync + Send>>, + > + Debug + + Sync + + Send, + > ProfilesLoading +{ + pub async fn new(secure_base_path: PathBuf, factory: Arc) -> anyhow::Result { + let fs = Fs::new(secure_base_path.clone()).await?; + let profiles_state = ProfilesState::load_or_default(&fs).await; + let mut profiles: HashMap> = Default::default(); + for (profile_key, profile) in profiles_state.profiles { + profiles.insert( + profile_key.clone(), + ActiveProfile { + client: Arc::new(factory(secure_base_path.join(profile_key)).await?), + cur_cert: Default::default(), + profile_data: profile, + }, + ); + } + Ok(Self { + profiles: parking_lot::Mutex::new(ActiveProfiles { + profiles, + cur_profile: profiles_state.cur_profile, + }), + factory, + fs, + secure_base_path, + }) + } +} diff --git a/lib/client-reqwest/Cargo.toml b/lib/client-reqwest/Cargo.toml new file mode 100644 index 0000000..792b558 --- /dev/null +++ b/lib/client-reqwest/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "client-reqwest" +version = "0.1.0" +edition = "2021" +authors = ["Jupeyy"] +license = "MIT OR Apache-2.0" +description = "The client implementation using reqwest as HTTP client." + +[dependencies] +client-http-fs = { version = "0.1.0", path = "../client-http-fs" } +account-client = { version = "0.1.0", path = "../account-client" } + +async-trait = "0.1.81" +url = { version = "2.5.2", features = ["serde"] } +reqwest = { version = "0.12.5" } +anyhow = { version = "1.0.86", features = ["backtrace"] } diff --git a/lib/client-reqwest/src/client.rs b/lib/client-reqwest/src/client.rs new file mode 100644 index 0000000..f272573 --- /dev/null +++ b/lib/client-reqwest/src/client.rs @@ -0,0 +1,102 @@ +use std::{ops::Deref, path::Path, sync::Arc}; + +use account_client::{ + errors::{FsLikeError, HttpLikeError}, + interface::Io, +}; +use async_trait::async_trait; +use client_http_fs::{client::ClientHttpTokioFs, fs::Fs, http::Http}; +use reqwest::header::{HeaderValue, CONTENT_TYPE}; +use url::Url; + +#[derive(Debug)] +pub struct HttpReqwest { + base_url: Url, + http: reqwest::Client, +} + +#[async_trait] +impl Http for HttpReqwest { + fn new(base_url: Url) -> Self + where + Self: Sized, + { + Self { + base_url, + http: reqwest::ClientBuilder::new().build().unwrap(), + } + } + async fn post_json(&self, url: Url, data: Vec) -> anyhow::Result, HttpLikeError> { + let res = self + .http + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(data) + .send() + .await + .map_err(|err| { + if err.is_request() { + HttpLikeError::Request + } else if err.is_status() { + HttpLikeError::Status(err.status().unwrap().as_u16()) + } else { + HttpLikeError::Other(err.into()) + } + })?; + Ok(res + .bytes() + .await + .map_err(|err| HttpLikeError::Other(err.into()))? + .to_vec()) + } + async fn get(&self, url: Url) -> anyhow::Result, HttpLikeError> { + let res = self.http.get(url).send().await.map_err(|err| { + if err.is_request() { + HttpLikeError::Request + } else if err.is_status() { + HttpLikeError::Status(err.status().unwrap().as_u16()) + } else { + HttpLikeError::Other(err.into()) + } + })?; + Ok(res + .bytes() + .await + .map_err(|err| HttpLikeError::Other(err.into()))? + .to_vec()) + } + fn base_url(&self) -> Url { + self.base_url.clone() + } +} + +#[derive(Debug)] +pub struct ClientReqwestTokioFs { + pub client: Arc, +} + +impl ClientReqwestTokioFs { + pub async fn new(base_urls: Vec, secure_path: &Path) -> anyhow::Result { + Ok(Self { + client: Arc::new(ClientHttpTokioFs { + http: base_urls + .into_iter() + .map(|base_url| { + let res: Arc = Arc::new(HttpReqwest::new(base_url)); + res + }) + .collect(), + cur_http: Default::default(), + fs: Fs::new(secure_path.into()).await?, + }), + }) + } +} + +impl Deref for ClientReqwestTokioFs { + type Target = dyn Io; + + fn deref(&self) -> &Self::Target { + self.client.as_ref() + } +} diff --git a/lib/client-reqwest/src/lib.rs b/lib/client-reqwest/src/lib.rs new file mode 100644 index 0000000..b9babe5 --- /dev/null +++ b/lib/client-reqwest/src/lib.rs @@ -0,0 +1 @@ +pub mod client; diff --git a/src/account_info.rs b/src/account_info.rs new file mode 100644 index 0000000..9c008ef --- /dev/null +++ b/src/account_info.rs @@ -0,0 +1,108 @@ +pub mod queries; + +use std::{str::FromStr, sync::Arc}; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + account_info::{AccountInfoResponse, CredentialType}, + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, + }, + client::account_info::AccountInfoRequest, +}; +use axum::Json; +use queries::AccountInfo; +use sqlx::{Acquire, AnyPool}; + +use crate::shared::{Shared, CERT_MAX_AGE_DELTA, CERT_MIN_AGE_DELTA}; + +pub async fn account_info_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json(account_info(shared, pool, data).await.map_err(|err| { + AccountServerRequestError::Unexpected { + target: "account_info".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + } + })) +} + +pub async fn account_info( + shared: Arc, + pool: AnyPool, + data: AccountInfoRequest, +) -> anyhow::Result { + data.account_data + .public_key + .verify_strict(data.time_stamp.to_string().as_bytes(), &data.signature)?; + let now = chrono::Utc::now(); + let delta = now.signed_duration_since(data.time_stamp); + anyhow::ensure!( + delta < CERT_MAX_AGE_DELTA && delta > CERT_MIN_AGE_DELTA, + "time stamp was not in a valid time frame." + ); + + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + // fetch account info + let qry = AccountInfo { + session_pub_key: data.account_data.public_key.as_bytes(), + session_hw_id: &data.account_data.hw_id, + }; + + let row = qry + .query(&shared.db.account_info) + .fetch_one(connection) + .await?; + + let account_info = AccountInfo::row_data(&row)?; + Ok(AccountInfoResponse { + account_id: account_info.account_id, + creation_date: account_info.creation_date, + credentials: account_info + .linked_email + .into_iter() + .flat_map(|mail| { + email_address::EmailAddress::from_str(&mail) + .ok() + .map(|mail| { + let repl_str = |str: &str| { + let str_count = str.chars().count(); + let mut str_new = str + .chars() + .next() + .map(|c| c.to_string()) + .unwrap_or_default(); + str_new.extend((0..str_count.saturating_sub(2)).map(|_| '*')); + if str_count >= 2 { + if let Some(last) = str.chars().last() { + str_new.push(last); + } + } + str_new + }; + let local = repl_str(mail.local_part()); + let (domain_name, domain_tld) = if let Some((domain_name, domain_tld)) = + mail.domain().split_once(".") + { + (repl_str(domain_name), format!(".{}", domain_tld)) + } else { + (repl_str(mail.domain()), "".to_string()) + }; + CredentialType::Email(format!("{}@{}{}", local, domain_name, domain_tld)) + }) + }) + .chain( + account_info + .linked_steam + .into_iter() + .map(CredentialType::Steam), + ) + .collect(), + }) +} diff --git a/src/account_info/mysql/account_info.sql b/src/account_info/mysql/account_info.sql new file mode 100644 index 0000000..2a5d96d --- /dev/null +++ b/src/account_info/mysql/account_info.sql @@ -0,0 +1,13 @@ +SELECT + account.id AS account_id, + account.create_time AS creation_date, + credential_email.email AS linked_email, + credential_steam.steamid64 AS linked_steam +FROM + account + INNER JOIN user_session ON user_session.account_id = account.id + LEFT JOIN credential_email ON credential_email.account_id = account.id + LEFT JOIN credential_steam ON credential_steam.account_id = account.id +WHERE + user_session.pub_key = ? + AND user_session.hw_id = ?; diff --git a/src/account_info/queries.rs b/src/account_info/queries.rs new file mode 100644 index 0000000..18746a9 --- /dev/null +++ b/src/account_info/queries.rs @@ -0,0 +1,57 @@ +use account_sql::query::Query; +use accounts_shared::client::machine_id::MachineUid; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Row; +use sqlx::Statement; + +pub struct AccountInfo<'a> { + pub session_pub_key: &'a [u8; 32], + pub session_hw_id: &'a MachineUid, +} + +pub struct AccountInfoData { + pub account_id: AccountId, + pub creation_date: sqlx::types::chrono::DateTime, + pub linked_email: Option, + pub linked_steam: Option, +} + +#[async_trait] +impl<'a> Query for AccountInfo<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/account_info.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement + .query() + .bind(self.session_pub_key.as_slice()) + .bind(self.session_hw_id.as_slice()) + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(AccountInfoData { + account_id: row + .try_get("account_id") + .map_err(|err| anyhow!("Failed get column account_id: {err}"))?, + creation_date: row + .try_get("creation_date") + .map_err(|err| anyhow!("Failed get column creation_date: {err}"))?, + linked_email: row + .try_get("linked_email") + .map_err(|err| anyhow!("Failed get column linked_email: {err}"))?, + linked_steam: row + .try_get("linked_steam") + .map_err(|err| anyhow!("Failed get column linked_steam: {err}"))?, + }) + } +} diff --git a/src/account_token.rs b/src/account_token.rs new file mode 100644 index 0000000..cedb9e4 --- /dev/null +++ b/src/account_token.rs @@ -0,0 +1,163 @@ +pub mod queries; + +use std::sync::Arc; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + account_token::AccountTokenError, errors::AccountServerRequestError, otp::generate_otp, + result::AccountServerReqResult, + }, + client::account_token::{ + AccountTokenEmailRequest, AccountTokenOperation, AccountTokenSteamRequest, + }, +}; +use axum::Json; +use queries::{AddAccountTokenEmail, AddAccountTokenSteam}; +use sqlx::{Acquire, AnyPool}; + +use crate::shared::Shared; + +pub async fn account_token_email( + shared: Arc, + pool: AnyPool, + requires_secret: bool, + Json(data): Json, +) -> Json> { + // After this check a validation process could be added + if requires_secret && data.secret_key.is_none() { + return Json(AccountServerReqResult::Err( + AccountServerRequestError::Other( + "This function is only for requests with a secret verification token.".to_string(), + ), + )); + } + Json( + account_token_email_impl(shared, pool, data) + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "account_token".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} + +pub async fn account_token_email_impl( + shared: Arc, + pool: AnyPool, + data: AccountTokenEmailRequest, +) -> anyhow::Result<()> { + anyhow::ensure!( + email_address::EmailAddress::parse_with_options(&data.email.email(), { + let options = email_address::Options::default() + .without_display_text() + .without_domain_literal(); + if shared.email.test_mode() && data.email.domain() == "localhost" { + options + } else { + options.with_required_tld() + } + }) + .ok() + .as_ref() + .map(|e| e.as_str()) + == Some(data.email.as_str()), + "Email must only contain email part with name & domain (name@example.com)" + ); + + // Add a account token and send it by email + let token = generate_otp(); + let token_hex = hex::encode(token); + let query_add_account_token = AddAccountTokenEmail { + token: &token, + email: &data.email, + ty: &data.op, + }; + let mut connection = pool.acquire().await?; + let con = connection.acquire().await?; + + let account_token_res = query_add_account_token + .query(&shared.db.account_token_email_statement) + .execute(&mut *con) + .await?; + anyhow::ensure!( + account_token_res.rows_affected() >= 1, + "No account token could be added." + ); + + let header = match data.op { + AccountTokenOperation::LogoutAll => "DDNet Logout All Sessions", + AccountTokenOperation::LinkCredential => "DDNet Link Credential", + AccountTokenOperation::Delete => "DDNet Delete Account", + }; + + let mail = shared.account_tokens_email.read().clone(); + let mail = mail + .replace("%SUBJECT%", data.email.local_part()) + .replace("%CODE%", &token_hex); + shared + .email + .send_email(data.email.as_str(), header, mail) + .await?; + + Ok(()) +} + +pub async fn account_token_steam( + shared: Arc, + pool: AnyPool, + requires_secret: bool, + Json(data): Json, +) -> Json> { + // After this check a validation process could be added + if requires_secret && data.secret_key.is_none() { + return Json(AccountServerReqResult::Err( + AccountServerRequestError::Other( + "This function is only for requests with a secret verification token.".to_string(), + ), + )); + } + Json( + account_token_steam_impl(shared, pool, data) + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "account_token".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} + +pub async fn account_token_steam_impl( + shared: Arc, + pool: AnyPool, + data: AccountTokenSteamRequest, +) -> anyhow::Result { + anyhow::ensure!( + data.steam_ticket.len() <= 1024, + "Steam session auth ticket must not be bigger than 1024 bytes." + ); + + // Add a account token and send it by steam + let token = generate_otp(); + let token_hex = hex::encode(token); + let query_add_account_token = AddAccountTokenSteam { + token: &token, + steamid64: &shared.steam.verify_steamid64(data.steam_ticket).await?, + ty: &data.op, + }; + let mut connection = pool.acquire().await?; + let con = connection.acquire().await?; + + let account_token_res = query_add_account_token + .query(&shared.db.account_token_steam_statement) + .execute(&mut *con) + .await?; + anyhow::ensure!( + account_token_res.rows_affected() >= 1, + "No account token could be added." + ); + + Ok(token_hex) +} diff --git a/src/account_token/mysql/account_token_data.sql b/src/account_token/mysql/account_token_data.sql new file mode 100644 index 0000000..7d01cb1 --- /dev/null +++ b/src/account_token/mysql/account_token_data.sql @@ -0,0 +1,8 @@ +SELECT + account_tokens.account_id, + account_tokens.ty +FROM + account_tokens +WHERE + account_tokens.token = ? + AND account_tokens.valid_until > UTC_TIMESTAMP(); diff --git a/src/account_token/mysql/add_account_token_email.sql b/src/account_token/mysql/add_account_token_email.sql new file mode 100644 index 0000000..ff1a4d3 --- /dev/null +++ b/src/account_token/mysql/add_account_token_email.sql @@ -0,0 +1,28 @@ +INSERT INTO + account_tokens ( + token, + valid_until, + account_id, + ty + ) +VALUES + ( + ?, + DATE_ADD(UTC_TIMESTAMP(), INTERVAL 15 MINUTE), + ( + SELECT + id + FROM + account + WHERE + id = ( + SELECT + account_id + FROM + credential_email + WHERE + email = ? + ) + ), + ? + ); diff --git a/src/account_token/mysql/add_account_token_steam.sql b/src/account_token/mysql/add_account_token_steam.sql new file mode 100644 index 0000000..b5fd850 --- /dev/null +++ b/src/account_token/mysql/add_account_token_steam.sql @@ -0,0 +1,28 @@ +INSERT INTO + account_tokens ( + token, + valid_until, + account_id, + ty + ) +VALUES + ( + ?, + DATE_ADD(UTC_TIMESTAMP(), INTERVAL 15 MINUTE), + ( + SELECT + id + FROM + account + WHERE + id = ( + SELECT + account_id + FROM + credential_steam + WHERE + steamid64 = ? + ) + ), + ? + ); diff --git a/src/account_token/mysql/invalidate_account_token.sql b/src/account_token/mysql/invalidate_account_token.sql new file mode 100644 index 0000000..0805364 --- /dev/null +++ b/src/account_token/mysql/invalidate_account_token.sql @@ -0,0 +1,4 @@ +DELETE FROM + account_tokens +WHERE + account_tokens.token = ?; diff --git a/src/account_token/queries.rs b/src/account_token/queries.rs new file mode 100644 index 0000000..185c96c --- /dev/null +++ b/src/account_token/queries.rs @@ -0,0 +1,138 @@ +use std::str::FromStr; + +use account_sql::query::Query; +use accounts_shared::client::account_token::AccountToken; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use async_trait::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Row; +use sqlx::Statement; + +use crate::types::AccountTokenType; + +#[derive(Debug)] +pub struct AddAccountTokenEmail<'a> { + pub token: &'a AccountToken, + pub email: &'a email_address::EmailAddress, + pub ty: &'a AccountTokenType, +} + +#[async_trait] +impl<'a> Query<()> for AddAccountTokenEmail<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/add_account_token_email.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + let ty: &'static str = self.ty.into(); + statement + .query() + .bind(self.token.as_slice()) + .bind(self.email.as_str()) + .bind(ty) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +#[derive(Debug)] +pub struct AddAccountTokenSteam<'a> { + pub token: &'a AccountToken, + pub steamid64: &'a i64, + pub ty: &'a AccountTokenType, +} + +#[async_trait] +impl<'a> Query<()> for AddAccountTokenSteam<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/add_account_token_steam.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + let ty: &'static str = self.ty.into(); + statement + .query() + .bind(self.token.as_slice()) + .bind(self.steamid64) + .bind(ty) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct AccountTokenQry<'a> { + pub token: &'a AccountToken, +} + +pub struct AccountTokenData { + pub account_id: AccountId, + pub ty: AccountTokenType, +} + +#[async_trait] +impl<'a> Query for AccountTokenQry<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/account_token_data.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.token.as_slice()) + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(AccountTokenData { + account_id: row + .try_get("account_id") + .map_err(|err| anyhow!("Failed get column account_id: {err}"))?, + ty: AccountTokenType::from_str( + row.try_get("ty") + .map_err(|err| anyhow!("Failed get column ty: {err}"))?, + )?, + }) + } +} + +pub struct InvalidateAccountToken<'a> { + pub token: &'a AccountToken, +} + +#[async_trait] +impl<'a> Query<()> for InvalidateAccountToken<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/invalidate_account_token.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.token.as_slice()) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/certs.rs b/src/certs.rs new file mode 100644 index 0000000..a7abe32 --- /dev/null +++ b/src/certs.rs @@ -0,0 +1,200 @@ +pub mod queries; + +use std::{str::FromStr, sync::Arc, time::Duration}; + +use account_sql::query::Query; +use accounts_shared::account_server::{ + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, +}; +use anyhow::anyhow; +use axum::Json; +use der::{Decode, Encode}; +use p256::ecdsa::{DerSignature, SigningKey}; +use queries::{AddCert, GetCerts}; +use serde::{Deserialize, Serialize}; +use sqlx::{Acquire, AnyPool, Executor}; +use x509_cert::{ + builder::{Builder, Profile}, + name::Name, + serial_number::SerialNumber, + spki::SubjectPublicKeyInfoOwned, + time::Validity, +}; + +use crate::{db::DbConnectionShared, shared::Shared}; + +#[derive(Debug, Clone)] +pub struct PrivateKeys { + pub current_key: SigningKey, + pub current_cert: x509_cert::Certificate, + pub next_key: SigningKey, + pub next_cert: x509_cert::Certificate, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PrivateKeysSer { + pub current_key: Vec, + pub current_cert: Vec, + pub next_key: Vec, + pub next_cert: Vec, +} + +impl Serialize for PrivateKeys { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let current_key = self.current_key.to_bytes().to_vec(); + let current_cert = self + .current_cert + .to_der() + .map_err(|_| serde::ser::Error::custom("cert to der failed"))?; + let next_key = self.next_key.to_bytes().to_vec(); + let next_cert = self + .next_cert + .to_der() + .map_err(|_| serde::ser::Error::custom("cert to der failed"))?; + + let keys = PrivateKeysSer { + current_key, + current_cert, + next_key, + next_cert, + }; + + keys.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PrivateKeys { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let keys = ::deserialize(deserializer)?; + + Ok(Self { + current_key: SigningKey::from_slice(&keys.current_key) + .map_err(|_| serde::de::Error::custom("reading signing key from slice failed."))?, + current_cert: x509_cert::Certificate::from_der(&keys.current_cert) + .map_err(|_| serde::de::Error::custom("reading cert from slice failed."))?, + next_key: SigningKey::from_slice(&keys.next_key) + .map_err(|_| serde::de::Error::custom("reading signing key from slice failed."))?, + next_cert: x509_cert::Certificate::from_der(&keys.next_cert) + .map_err(|_| serde::de::Error::custom("reading cert from slice failed."))?, + }) + } +} + +pub fn generate_key_and_cert_impl( + valid_for: Duration, +) -> anyhow::Result<(SigningKey, x509_cert::Certificate)> { + let signing_key = SigningKey::random(&mut rand::rngs::OsRng); + let verifying_key = signing_key.verifying_key(); + + let serial_number = SerialNumber::from(42u32); + let validity = Validity::from_now(valid_for)?; + let profile = Profile::Root; + let subject = Name::from_str("CN=DDNet,O=DDNet.org,C=EU")?; + + let pub_key = SubjectPublicKeyInfoOwned::from_key(*verifying_key)?; + + let cert = x509_cert::builder::CertificateBuilder::new( + profile, + serial_number, + validity, + subject, + pub_key, + &signing_key, + )? + .build::()?; + + Ok((signing_key, cert)) +} + +pub fn generate_key_and_cert( + first_key: bool, +) -> anyhow::Result<(SigningKey, x509_cert::Certificate)> { + generate_key_and_cert_impl(Duration::new( + if first_key { 1 } else { 2 } * 30 * 24 * 60 * 60, + 0, + )) +} + +pub async fn store_cert( + db: &DbConnectionShared, + pool: &AnyPool, + cert: &x509_cert::Certificate, +) -> anyhow::Result<()> { + let cert_der = cert.to_der()?; + let time_stamp = cert + .tbs_certificate + .validity + .not_after + .to_date_time() + .unix_duration(); + let valid_until = >::from_timestamp( + time_stamp.as_secs() as i64, + time_stamp.subsec_nanos(), + ) + .ok_or_else(|| anyhow!("not a valid utc timestamp"))?; + let qry = AddCert { + cert_der: &cert_der, + valid_until: &valid_until, + }; + + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + let res = connection + .execute(qry.query(&db.add_cert_statement)) + .await?; + anyhow::ensure!(res.rows_affected() >= 1); + + Ok(()) +} + +pub async fn get_certs( + db: &DbConnectionShared, + pool: &AnyPool, +) -> anyhow::Result> { + let qry = GetCerts {}; + + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + let cert_rows = connection + .fetch_all(qry.query(&db.get_certs_statement)) + .await?; + + cert_rows + .into_iter() + .map(|row| GetCerts::row_data(&row)) + .collect::>>() + .and_then(|certs| { + certs + .into_iter() + .map(|cert| { + x509_cert::Certificate::from_der(&cert.cert_der).map_err(|err| anyhow!(err)) + }) + .collect::>>() + }) +} + +pub async fn certs_request( + shared: Arc, +) -> Json>, Empty>> { + let certs = shared.cert_chain.read().clone(); + Json( + certs + .iter() + .map(|cert| cert.to_der().map_err(|err| anyhow!(err))) + .collect::>>() + .map_err(|err| AccountServerRequestError::Unexpected { + target: "certs_request".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} diff --git a/src/certs/mysql/add_cert.sql b/src/certs/mysql/add_cert.sql new file mode 100644 index 0000000..6bb7479 --- /dev/null +++ b/src/certs/mysql/add_cert.sql @@ -0,0 +1,4 @@ +INSERT INTO + certs (cert_der, valid_until) +VALUES + (?, ?); diff --git a/src/certs/mysql/get_certs.sql b/src/certs/mysql/get_certs.sql new file mode 100644 index 0000000..a8e1c03 --- /dev/null +++ b/src/certs/mysql/get_certs.sql @@ -0,0 +1,6 @@ +SELECT + certs.cert_der +FROM + certs +WHERE + certs.valid_until > UTC_TIMESTAMP(); diff --git a/src/certs/queries.rs b/src/certs/queries.rs new file mode 100644 index 0000000..8cfd7fe --- /dev/null +++ b/src/certs/queries.rs @@ -0,0 +1,62 @@ +use account_sql::query::Query; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Row; +use sqlx::Statement; + +pub struct AddCert<'a> { + pub cert_der: &'a [u8], + pub valid_until: &'a sqlx::types::chrono::DateTime, +} + +#[async_trait] +impl<'a> Query<()> for AddCert<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/add_cert.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.cert_der).bind(self.valid_until) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct GetCerts {} + +pub struct SingleCertData { + pub cert_der: Vec, +} + +#[async_trait] +impl Query for GetCerts { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/get_certs.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query() + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(SingleCertData { + cert_der: row + .try_get("cert_der") + .map_err(|err| anyhow!("Failed get column cert_der: {err}"))?, + }) + } +} diff --git a/src/credential_auth_token.rs b/src/credential_auth_token.rs new file mode 100644 index 0000000..b336859 --- /dev/null +++ b/src/credential_auth_token.rs @@ -0,0 +1,188 @@ +pub mod queries; + +use std::sync::Arc; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + credential_auth_token::CredentialAuthTokenError, errors::AccountServerRequestError, + otp::generate_otp, result::AccountServerReqResult, + }, + client::credential_auth_token::{ + CredentialAuthTokenEmailRequest, CredentialAuthTokenOperation, + CredentialAuthTokenSteamRequest, + }, +}; +use axum::Json; +use sqlx::{Acquire, AnyPool}; + +use crate::{ + credential_auth_token::queries::AddCredentialAuthToken, shared::Shared, types::TokenType, +}; + +pub async fn credential_auth_token_email( + shared: Arc, + pool: AnyPool, + requires_secret: bool, + Json(data): Json, +) -> Json> { + // Check allow & deny lists + if !shared.email.allow_list.read().is_allowed(&data.email) { + return Json(AccountServerReqResult::Err( + AccountServerRequestError::Other( + "An email from that domain is not in the allowed list of email domains." + .to_string(), + ), + )); + } + if shared.email.deny_list.read().is_banned(&data.email) { + return Json(AccountServerReqResult::Err( + AccountServerRequestError::Other( + "An email from that domain is banned and thus not allowed.".to_string(), + ), + )); + } + + // Before this call a validation process could be added + if requires_secret && data.secret_key.is_none() { + return Json(AccountServerReqResult::Err( + AccountServerRequestError::Other( + "This function is only for requests with a secret verification token.".to_string(), + ), + )); + } + Json( + credential_auth_token_email_impl(shared, pool, data) + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "credential_auth_token_email".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} + +pub async fn credential_auth_token_email_impl( + shared: Arc, + pool: AnyPool, + data: CredentialAuthTokenEmailRequest, +) -> anyhow::Result<()> { + anyhow::ensure!( + email_address::EmailAddress::parse_with_options(&data.email.email(), { + let options = email_address::Options::default() + .without_display_text() + .without_domain_literal(); + if shared.email.test_mode() && data.email.domain() == "localhost" { + options + } else { + options.with_required_tld() + } + }) + .ok() + .as_ref() + .map(|e| e.as_str()) + == Some(data.email.as_str()), + "Email must only contain email part with name & domain (name@example.com)" + ); + + // write the new account to the database + // Add a credential auth token and send it by email + let token = generate_otp(); + let token_hex = hex::encode(token); + let query_add_credential_auth_token = AddCredentialAuthToken { + token: &token, + ty: &TokenType::Email, + identifier: data.email.as_str(), + op: &data.op, + }; + let mut connection = pool.acquire().await?; + let con = connection.acquire().await?; + + let credential_auth_token_res = query_add_credential_auth_token + .query(&shared.db.credential_auth_token_statement) + .execute(&mut *con) + .await?; + anyhow::ensure!( + credential_auth_token_res.rows_affected() >= 1, + "No credential auth token could be added." + ); + + let header = match data.op { + CredentialAuthTokenOperation::Login => "DDNet Account Login", + CredentialAuthTokenOperation::LinkCredential => "DDNet Link E-mail To Account", + CredentialAuthTokenOperation::UnlinkCredential => "DDNet Unlink Credential", + }; + + let mail = shared.credential_auth_tokens_email.read().clone(); + let mail = mail + .replace("%SUBJECT%", data.email.local_part()) + .replace("%CODE%", &token_hex); + shared + .email + .send_email(data.email.as_str(), header, mail) + .await?; + + Ok(()) +} + +pub async fn credential_auth_token_steam( + shared: Arc, + pool: AnyPool, + requires_secret: bool, + Json(data): Json, +) -> Json> { + // After this check a validation process could be added + if requires_secret && data.secret_key.is_none() { + return Json(AccountServerReqResult::Err( + AccountServerRequestError::Other( + "This function is only for requests with a secret verification token.".to_string(), + ), + )); + } + Json( + credential_auth_token_steam_impl(shared, pool, data) + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "credential_auth_token_steam".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} + +pub async fn credential_auth_token_steam_impl( + shared: Arc, + pool: AnyPool, + data: CredentialAuthTokenSteamRequest, +) -> anyhow::Result { + anyhow::ensure!( + data.steam_ticket.len() <= 1024, + "Steam session auth ticket must not be bigger than 1024 bytes." + ); + + let steamid64 = shared.steam.verify_steamid64(data.steam_ticket).await?; + + // write the new account to the database + // Add a credential auth token and send it by steam + let token = generate_otp(); + let token_hex = hex::encode(token); + let query_add_credential_auth_token = AddCredentialAuthToken { + token: &token, + ty: &TokenType::Steam, + identifier: &steamid64.to_string(), + op: &data.op, + }; + let mut connection = pool.acquire().await?; + let con = connection.acquire().await?; + + let credential_auth_token_res = query_add_credential_auth_token + .query(&shared.db.credential_auth_token_statement) + .execute(&mut *con) + .await?; + anyhow::ensure!( + credential_auth_token_res.rows_affected() >= 1, + "No credential auth token could be added." + ); + + Ok(token_hex) +} diff --git a/src/credential_auth_token/mysql/add_credential_auth_token.sql b/src/credential_auth_token/mysql/add_credential_auth_token.sql new file mode 100644 index 0000000..5df74a7 --- /dev/null +++ b/src/credential_auth_token/mysql/add_credential_auth_token.sql @@ -0,0 +1,16 @@ +INSERT INTO + credential_auth_tokens ( + token, + valid_until, + ty, + identifier, + op + ) +VALUES + ( + ?, + DATE_ADD(UTC_TIMESTAMP(), INTERVAL 15 MINUTE), + ?, + ?, + ? + ); diff --git a/src/credential_auth_token/queries.rs b/src/credential_auth_token/queries.rs new file mode 100644 index 0000000..799fc26 --- /dev/null +++ b/src/credential_auth_token/queries.rs @@ -0,0 +1,44 @@ +use account_sql::query::Query; +use accounts_shared::client::credential_auth_token::CredentialAuthTokenOperation; +use accounts_shared::client::login::CredentialAuthToken; +use anyhow::anyhow; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +use crate::types::TokenType; + +#[derive(Debug)] +pub struct AddCredentialAuthToken<'a> { + pub token: &'a CredentialAuthToken, + pub ty: &'a TokenType, + pub identifier: &'a str, + pub op: &'a CredentialAuthTokenOperation, +} + +#[async_trait::async_trait] +impl<'a> Query<()> for AddCredentialAuthToken<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/add_credential_auth_token.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + let ty: &'static str = self.ty.into(); + let op: &'static str = self.op.into(); + statement + .query() + .bind(self.token.as_slice()) + .bind(ty) + .bind(self.identifier) + .bind(op) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..acbe6bd --- /dev/null +++ b/src/db.rs @@ -0,0 +1,33 @@ +use sqlx::any::AnyStatement; + +/// Shared data for a db connection +pub struct DbConnectionShared { + pub credential_auth_token_statement: AnyStatement<'static>, + pub credential_auth_token_qry_statement: AnyStatement<'static>, + pub invalidate_credential_auth_token_statement: AnyStatement<'static>, + pub try_create_account_statement: AnyStatement<'static>, + pub account_id_from_last_insert_qry_statement: AnyStatement<'static>, + pub account_id_from_email_qry_statement: AnyStatement<'static>, + pub account_id_from_steam_qry_statement: AnyStatement<'static>, + pub link_credentials_email_qry_statement: AnyStatement<'static>, + pub link_credentials_steam_qry_statement: AnyStatement<'static>, + pub create_session_statement: AnyStatement<'static>, + pub logout_statement: AnyStatement<'static>, + pub auth_attempt_statement: AnyStatement<'static>, + pub account_token_email_statement: AnyStatement<'static>, + pub account_token_steam_statement: AnyStatement<'static>, + pub account_token_qry_statement: AnyStatement<'static>, + pub invalidate_account_token_statement: AnyStatement<'static>, + pub remove_sessions_except_statement: AnyStatement<'static>, + pub remove_account_statement: AnyStatement<'static>, + pub add_cert_statement: AnyStatement<'static>, + pub get_certs_statement: AnyStatement<'static>, + pub cleanup_credential_auth_tokens_statement: AnyStatement<'static>, + pub cleanup_account_tokens_statement: AnyStatement<'static>, + pub cleanup_certs_statement: AnyStatement<'static>, + pub unlink_credential_email_statement: AnyStatement<'static>, + pub unlink_credential_steam_statement: AnyStatement<'static>, + pub unlink_credential_by_email_statement: AnyStatement<'static>, + pub unlink_credential_by_steam_statement: AnyStatement<'static>, + pub account_info: AnyStatement<'static>, +} diff --git a/src/delete.rs b/src/delete.rs new file mode 100644 index 0000000..c0972b8 --- /dev/null +++ b/src/delete.rs @@ -0,0 +1,115 @@ +pub mod queries; + +use std::sync::Arc; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, + }, + client::delete::DeleteRequest, +}; +use axum::Json; +use sqlx::{Acquire, AnyPool, Connection}; + +use crate::{ + account_token::queries::{AccountTokenQry, InvalidateAccountToken}, + link_credential::queries::{UnlinkCredentialEmail, UnlinkCredentialSteam}, + logout_all::queries::RemoveSessionsExcept, + shared::Shared, + types::AccountTokenType, +}; + +use self::queries::RemoveAccount; + +pub async fn delete_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json( + delete(shared, pool, data) + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "delete_request".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} + +pub async fn delete(shared: Arc, pool: AnyPool, data: DeleteRequest) -> anyhow::Result<()> { + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + connection + .transaction(|connection| { + Box::pin(async move { + // token data + let acc_token_qry = AccountTokenQry { + token: &data.account_token, + }; + + let row = acc_token_qry + .query(&shared.db.account_token_qry_statement) + .fetch_one(&mut **connection) + .await?; + + let token_data = AccountTokenQry::row_data(&row)?; + + // invalidate token + let qry = InvalidateAccountToken { + token: &data.account_token, + }; + qry.query(&shared.db.invalidate_account_token_statement) + .execute(&mut **connection) + .await?; + + anyhow::ensure!( + token_data.ty == AccountTokenType::Delete, + "Account token was not for delete operation." + ); + let account_id = token_data.account_id; + + // remove all sessions + let qry = RemoveSessionsExcept { + account_id: &account_id, + session_data: &None, + }; + + qry.query(&shared.db.remove_sessions_except_statement) + .execute(&mut **connection) + .await?; + + // Unlink all credentials + let qry = UnlinkCredentialEmail { + account_id: &account_id, + }; + qry.query(&shared.db.unlink_credential_email_statement) + .execute(&mut **connection) + .await?; + + let qry = UnlinkCredentialSteam { + account_id: &account_id, + }; + qry.query(&shared.db.unlink_credential_steam_statement) + .execute(&mut **connection) + .await?; + + // delete account + let qry = RemoveAccount { + account_id: &account_id, + }; + + qry.query(&shared.db.remove_account_statement) + .execute(&mut **connection) + .await?; + + anyhow::Ok(()) + }) + }) + .await?; + + Ok(()) +} diff --git a/src/delete/mysql/rem_account.sql b/src/delete/mysql/rem_account.sql new file mode 100644 index 0000000..64fcfbe --- /dev/null +++ b/src/delete/mysql/rem_account.sql @@ -0,0 +1,4 @@ +DELETE FROM + account +WHERE + account.id = ?; diff --git a/src/delete/queries.rs b/src/delete/queries.rs new file mode 100644 index 0000000..33a423d --- /dev/null +++ b/src/delete/queries.rs @@ -0,0 +1,31 @@ +use account_sql::query::Query; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +pub struct RemoveAccount<'a> { + pub account_id: &'a AccountId, +} + +#[async_trait] +impl<'a> Query<()> for RemoveAccount<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/rem_account.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.account_id) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 0000000..9ff67a4 --- /dev/null +++ b/src/email.rs @@ -0,0 +1,149 @@ +use std::{fmt::Debug, path::Path, sync::Arc}; + +use lettre::{ + message::SinglePart, transport::smtp::authentication::Credentials, Message, SmtpTransport, + Transport, +}; +use parking_lot::RwLock; + +use crate::{ + email_limit::{EmailDomainAllowList, EmailDomainDenyList}, + file_watcher::FileWatcher, +}; + +pub trait EmailHook: Debug + Sync + Send { + fn on_mail(&self, email_subject: &str, email_body: &str); +} + +#[derive(Debug)] +struct EmailHookDummy {} +impl EmailHook for EmailHookDummy { + fn on_mail(&self, _email_subject: &str, _email_body: &str) { + // empty + } +} + +/// Shared email helper +#[derive(Debug)] +pub struct EmailShared { + smtp: SmtpTransport, + pub email_from: String, + mail_hook: Arc, + + pub deny_list: RwLock, + pub allow_list: RwLock, + + pub test_mode: bool, +} + +impl EmailShared { + pub async fn new( + relay: &str, + relay_port: u16, + from_email: &str, + username: &str, + password: &str, + ) -> anyhow::Result { + let smtp = SmtpTransport::relay(relay)? + .port(relay_port) + .credentials(Credentials::new(username.into(), password.into())) + .build(); + + anyhow::ensure!( + smtp.test_connection()?, + "Could not connect to smtp server: {}", + relay + ); + Ok(Self { + smtp, + mail_hook: Arc::new(EmailHookDummy {}), + email_from: from_email.into(), + + deny_list: RwLock::new(EmailDomainDenyList::load_from_file().await), + allow_list: RwLock::new(EmailDomainAllowList::load_from_file().await), + + test_mode: false, + }) + } + + /// A hook that can see all sent emails + /// Currently only useful for testing + #[allow(dead_code)] + pub fn set_hook(&mut self, hook: F) { + self.mail_hook = Arc::new(hook); + } + + pub async fn send_email( + &self, + to: &str, + subject: &str, + html_body: String, + ) -> anyhow::Result<()> { + self.mail_hook.on_mail(subject, &html_body); + let email = Message::builder() + .from(self.email_from.parse().unwrap()) + .to(to.parse().unwrap()) + .subject(subject) + .singlepart(SinglePart::html(html_body)) + .unwrap(); + self.smtp.send(&email)?; + + Ok(()) + } + + const PATH: &str = "config/"; + pub async fn load_email_template(name: &str) -> anyhow::Result { + let path: &Path = Self::PATH.as_ref(); + Ok(tokio::fs::read_to_string(path.join(name)).await?) + } + + pub fn watcher(name: &str) -> FileWatcher { + FileWatcher::new(Self::PATH.as_ref(), name.as_ref()) + } + + #[cfg(test)] + pub fn set_test_mode(&mut self, test_mode: bool) { + self.test_mode = test_mode; + } + pub const fn test_mode(&self) -> bool { + self.test_mode + } +} + +impl From<(&str, SmtpTransport)> for EmailShared { + fn from((email_from, smtp): (&str, SmtpTransport)) -> Self { + Self { + smtp, + mail_hook: Arc::new(EmailHookDummy {}), + email_from: email_from.into(), + + deny_list: Default::default(), + allow_list: Default::default(), + + test_mode: false, + } + } +} + +#[cfg(test)] +mod test { + use lettre::SmtpTransport; + + use crate::email::EmailShared; + + #[tokio::test] + async fn email_test() { + let email: EmailShared = ("test@localhost", SmtpTransport::unencrypted_localhost()).into(); + + assert!(email.smtp.test_connection().unwrap()); + + email + .send_email( + "TestTo ", + "It works", + "It indeed works".to_string(), + ) + .await + .unwrap(); + } +} diff --git a/src/email_limit.rs b/src/email_limit.rs new file mode 100644 index 0000000..5fec9ff --- /dev/null +++ b/src/email_limit.rs @@ -0,0 +1,106 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; + +use crate::file_watcher::FileWatcher; + +#[derive(Debug, Default)] +pub struct EmailDomainDenyList { + pub domains: HashSet, +} + +impl EmailDomainDenyList { + pub fn is_banned(&self, email: &email_address::EmailAddress) -> bool { + !url::Host::parse(&email.domain().to_lowercase()) + .is_ok_and(|host| !self.domains.contains(&host)) + } + + const PATH: &str = "config/"; + const FILE: &str = "email_domain_ban.txt"; + fn file_path() -> PathBuf { + let path: &Path = Self::PATH.as_ref(); + path.join(Self::FILE) + } + pub async fn load_from_file() -> Self { + let mut res = Self::default(); + match tokio::fs::read_to_string(Self::file_path()).await { + Ok(file) => { + for line in file.lines() { + match url::Host::parse(line) { + Ok(host) => { + res.domains.insert(host); + } + Err(err) => { + log::error!("{err}"); + } + } + } + } + Err(err) => { + if matches!(err.kind(), std::io::ErrorKind::NotFound) { + let _ = tokio::fs::write(Self::file_path(), vec![]).await; + } else { + log::error!("{err}"); + } + } + } + res + } + + pub fn watcher() -> FileWatcher { + FileWatcher::new(Self::PATH.as_ref(), Self::FILE.as_ref()) + } +} + +/// Checks if a email domain is allowed. +/// If the list is empty, all domains are allowed. +#[derive(Debug, Default)] +pub struct EmailDomainAllowList { + pub domains: HashSet, +} + +impl EmailDomainAllowList { + pub fn is_allowed(&self, email: &email_address::EmailAddress) -> bool { + self.domains.is_empty() + || url::Host::parse(&email.domain().to_lowercase()) + .is_ok_and(|host| self.domains.contains(&host)) + } + + const PATH: &str = "config/"; + const FILE: &str = "email_domain_allow.txt"; + fn file_path() -> PathBuf { + let path: &Path = Self::PATH.as_ref(); + path.join(Self::FILE) + } + + pub async fn load_from_file() -> Self { + let mut res = Self::default(); + match tokio::fs::read_to_string(Self::file_path()).await { + Ok(file) => { + for line in file.lines() { + match url::Host::parse(line) { + Ok(host) => { + res.domains.insert(host); + } + Err(err) => { + log::error!("{err}"); + } + } + } + } + Err(err) => { + if matches!(err.kind(), std::io::ErrorKind::NotFound) { + let _ = tokio::fs::write(Self::file_path(), vec![]).await; + } else { + log::error!("{err}"); + } + } + } + res + } + + pub fn watcher() -> FileWatcher { + FileWatcher::new(Self::PATH.as_ref(), Self::FILE.as_ref()) + } +} diff --git a/src/file_watcher.rs b/src/file_watcher.rs new file mode 100644 index 0000000..6a79254 --- /dev/null +++ b/src/file_watcher.rs @@ -0,0 +1,99 @@ +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; + +use notify::{ + event::RenameMode, recommended_watcher, Event, EventHandler, RecommendedWatcher, RecursiveMode, + Watcher, +}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +#[derive(Debug)] +pub struct FileWatcher { + rx: UnboundedReceiver>, + _watcher: Option, + file: PathBuf, +} + +impl FileWatcher { + pub fn new(path: &Path, file: &Path) -> Self { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + + struct TokioSender(UnboundedSender>); + impl EventHandler for TokioSender { + fn handle_event(&mut self, event: notify::Result) { + let _ = self.0.send(event); + } + } + let mut watcher = recommended_watcher(TokioSender(tx)).unwrap(); + + if let Err(err) = watcher.watch(path, RecursiveMode::Recursive) { + log::info!(target: "fs-watch", "could not watch directory/file: {err}"); + } + + let file = std::path::absolute(path.join(file)).unwrap(); + + Self { + rx, + _watcher: Some(watcher), + file, + } + } + + pub async fn wait_for_change(&mut self) -> anyhow::Result<()> { + loop { + match self.rx.recv().await { + Some(Ok(ev)) => { + let handle_ev = matches!( + ev.kind, + notify::EventKind::Access(notify::event::AccessKind::Close( + notify::event::AccessMode::Write + )) | notify::EventKind::Modify(notify::event::ModifyKind::Name( + RenameMode::Both | RenameMode::To | RenameMode::From, + )) | notify::EventKind::Remove( + notify::event::RemoveKind::File | notify::event::RemoveKind::Folder + ) + ); + // check if the file exists + let file_exists = ev.paths.iter().any(|path| self.file.eq(path)); + if file_exists && handle_ev { + // if the file exist, make sure the file is not modified for at least 1 second + let mut last_modified = None; + + while let Ok(file) = tokio::fs::File::open(&self.file).await { + if let Some(modified) = file + .metadata() + .await + .ok() + .and_then(|metadata| metadata.modified().ok()) + { + if let Some(file_last_modified) = last_modified { + if modified == file_last_modified { + break; + } else { + // else try again + last_modified = Some(modified); + } + } else { + last_modified = Some(modified); + } + } else { + break; + } + drop(file); + tokio::time::sleep(Duration::from_secs(1)).await; + } + return Ok(()); + } + } + Some(Err(err)) => { + log::error!(target: "file-watcher", "event err: {err}"); + } + None => { + return Err(anyhow::anyhow!("Channel closed")); + } + } + } + } +} diff --git a/src/ip_limit.rs b/src/ip_limit.rs new file mode 100644 index 0000000..a5d11e2 --- /dev/null +++ b/src/ip_limit.rs @@ -0,0 +1,97 @@ +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; + +use accounts_shared::account_server::{ + errors::AccountServerRequestError, result::AccountServerReqResult, +}; +use axum::{ + body::Body, + extract::{ConnectInfo, State}, + http::Request, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use parking_lot::RwLock; +use reqwest::StatusCode; + +use crate::file_watcher::FileWatcher; + +#[derive(Debug, Default)] +pub struct IpDenyList { + pub ipv4: iprange::IpRange, + pub ipv6: iprange::IpRange, +} + +impl IpDenyList { + pub fn is_banned(&self, addr: SocketAddr) -> bool { + match addr { + SocketAddr::V4(ip) => self.ipv4.contains(&ipnet::Ipv4Net::from(*ip.ip())), + SocketAddr::V6(ip) => self.ipv6.contains(&ipnet::Ipv6Net::from(*ip.ip())), + } + } + + const PATH: &str = "config/"; + const FILE: &str = "ip_ban.txt"; + fn file_path() -> PathBuf { + let path: &Path = Self::PATH.as_ref(); + path.join(Self::FILE) + } + + pub async fn load_from_file() -> Self { + let mut res = Self::default(); + match tokio::fs::read_to_string(Self::file_path()).await { + Ok(file) => { + for line in file.lines() { + match ipnet::IpNet::from_str(line) { + Ok(ip) => match ip { + ipnet::IpNet::V4(ipv4_net) => { + res.ipv4.add(ipv4_net); + } + ipnet::IpNet::V6(ipv6_net) => { + res.ipv6.add(ipv6_net); + } + }, + Err(err) => { + log::error!("{err}"); + } + } + } + } + Err(err) => { + if matches!(err.kind(), std::io::ErrorKind::NotFound) { + let _ = tokio::fs::write(Self::file_path(), vec![]).await; + } else { + log::error!("{err}"); + } + } + } + res + } + + pub fn watcher() -> FileWatcher { + FileWatcher::new(Self::PATH.as_ref(), Self::FILE.as_ref()) + } +} + +pub async fn ip_deny_layer( + State(deny_list): State>>, + ConnectInfo(client_ip): ConnectInfo, + req: Request, + next: Next, +) -> Result, StatusCode> { + if deny_list.read().is_banned(client_ip) { + Ok(Json(AccountServerReqResult::<(), ()>::Err( + AccountServerRequestError::VpnBan( + "VPN detected. Please deactivate the VPN and try again.".to_string(), + ), + )) + .into_response()) + } else { + Ok(next.run(req).await) + } +} diff --git a/src/link_credential.rs b/src/link_credential.rs new file mode 100644 index 0000000..3d9a8c8 --- /dev/null +++ b/src/link_credential.rs @@ -0,0 +1,157 @@ +pub mod queries; + +use std::{str::FromStr, sync::Arc}; + +use account_sql::{is_duplicate_entry, query::Query}; +use accounts_shared::{ + account_server::{ + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, + }, + client::{ + credential_auth_token::CredentialAuthTokenOperation, link_credential::LinkCredentialRequest, + }, +}; +use axum::Json; +use queries::{UnlinkCredentialEmail, UnlinkCredentialSteam}; +use sqlx::{Acquire, AnyPool, Connection}; + +use crate::{ + account_token::queries::{AccountTokenQry, InvalidateAccountToken}, + login::{ + get_and_invalidate_credential_auth_token, + queries::{LinkAccountCredentialEmail, LinkAccountCredentialSteam}, + }, + shared::Shared, + types::{AccountTokenType, TokenType}, +}; + +pub async fn link_credential_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json(link_credential(shared, pool, data).await.map_err(|err| { + AccountServerRequestError::Unexpected { + target: "link_credential".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + } + })) +} + +pub async fn link_credential( + shared: Arc, + pool: AnyPool, + data: LinkCredentialRequest, +) -> anyhow::Result<()> { + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + connection + .transaction(|connection| { + Box::pin(async move { + // token data + let acc_token_qry = AccountTokenQry { + token: &data.account_token, + }; + + let row = acc_token_qry + .query(&shared.db.account_token_qry_statement) + .fetch_one(&mut **connection) + .await?; + + let token_data = AccountTokenQry::row_data(&row)?; + + // invalidate token + let qry = InvalidateAccountToken { + token: &data.account_token, + }; + qry.query(&shared.db.invalidate_account_token_statement) + .execute(&mut **connection) + .await?; + + anyhow::ensure!( + token_data.ty == AccountTokenType::LinkCredential, + "Account token was not for logout all operation." + ); + let account_id = token_data.account_id; + + let token_data = get_and_invalidate_credential_auth_token( + &shared, + data.credential_auth_token, + connection, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("Credential auth token is invalid/expired."))?; + anyhow::ensure!( + token_data.op == CredentialAuthTokenOperation::LinkCredential, + "Credential auth token was not for linking a new credential" + ); + + match token_data.ty { + TokenType::Email => { + let email = email_address::EmailAddress::from_str(&token_data.identifier)?; + // remove the current email, if exists. + let qry = UnlinkCredentialEmail { + account_id: &account_id, + }; + + qry.query(&shared.db.unlink_credential_email_statement) + .execute(&mut **connection) + .await?; + + // add the new email. + let qry = LinkAccountCredentialEmail { + account_id: &account_id, + email: &email, + }; + + let res = qry + .query(&shared.db.link_credentials_email_qry_statement) + .execute(&mut **connection) + .await; + + anyhow::ensure!( + !is_duplicate_entry(&res), + "This email is already used for a different account." + ); + res?; + } + TokenType::Steam => { + let steamid64: i64 = token_data.identifier.parse()?; + // remove the current steam, if exists. + let qry = UnlinkCredentialSteam { + account_id: &account_id, + }; + + qry.query(&shared.db.unlink_credential_steam_statement) + .execute(&mut **connection) + .await?; + + // add the new steam. + let qry = LinkAccountCredentialSteam { + account_id: &account_id, + steamid64: &steamid64, + }; + + let res = qry + .query(&shared.db.link_credentials_steam_qry_statement) + .execute(&mut **connection) + .await; + + anyhow::ensure!( + !is_duplicate_entry(&res), + "This email is already used for a different account." + ); + res?; + } + } + + anyhow::Ok(()) + }) + }) + .await?; + + Ok(()) +} diff --git a/src/link_credential/mysql/unlink_credential_email.sql b/src/link_credential/mysql/unlink_credential_email.sql new file mode 100644 index 0000000..3188d06 --- /dev/null +++ b/src/link_credential/mysql/unlink_credential_email.sql @@ -0,0 +1,4 @@ +DELETE FROM + credential_email +WHERE + account_id = ?; diff --git a/src/link_credential/mysql/unlink_credential_steam.sql b/src/link_credential/mysql/unlink_credential_steam.sql new file mode 100644 index 0000000..4a66591 --- /dev/null +++ b/src/link_credential/mysql/unlink_credential_steam.sql @@ -0,0 +1,4 @@ +DELETE FROM + credential_steam +WHERE + account_id = ?; diff --git a/src/link_credential/queries.rs b/src/link_credential/queries.rs new file mode 100644 index 0000000..e9f8293 --- /dev/null +++ b/src/link_credential/queries.rs @@ -0,0 +1,55 @@ +use account_sql::query::Query; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +pub struct UnlinkCredentialEmail<'a> { + pub account_id: &'a AccountId, +} + +#[async_trait] +impl<'a> Query<()> for UnlinkCredentialEmail<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/unlink_credential_email.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.account_id) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct UnlinkCredentialSteam<'a> { + pub account_id: &'a AccountId, +} + +#[async_trait] +impl<'a> Query<()> for UnlinkCredentialSteam<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/unlink_credential_steam.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.account_id) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..0709af1 --- /dev/null +++ b/src/login.rs @@ -0,0 +1,239 @@ +pub mod queries; + +use std::{str::FromStr, sync::Arc}; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + errors::AccountServerRequestError, login::LoginError, result::AccountServerReqResult, + }, + client::login::{CredentialAuthToken, LoginRequest}, +}; +use accounts_types::account_id::AccountId; +use axum::Json; +use queries::{ + AccountIdFromEmail, AccountIdFromLastInsert, AccountIdFromSteam, CredentialAuthTokenData, + LinkAccountCredentialEmail, LinkAccountCredentialSteam, +}; +use sqlx::{Acquire, AnyConnection, AnyPool, Connection}; + +use crate::{ + shared::Shared, + types::{CredentialAuthTokenType, TokenType}, +}; + +use self::queries::{ + CreateSession, CredentialAuthTokenQry, InvalidateCredentialAuthToken, TryCreateAccount, +}; + +pub async fn login_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json(login(shared, pool, data).await) +} + +#[derive(Debug, Clone)] +enum LoginResponse { + /// Worked + Success(AccountId), + /// Token invalid, probably timed out + TokenInvalid, +} + +pub async fn get_and_invalidate_credential_auth_token( + shared: &Arc, + credential_auth_token: CredentialAuthToken, + connection: &mut AnyConnection, +) -> anyhow::Result> { + // token data + let credential_auth_token_qry = CredentialAuthTokenQry { + token: &credential_auth_token, + }; + + let row = credential_auth_token_qry + .query(&shared.db.credential_auth_token_qry_statement) + .fetch_optional(connection) + .await?; + + match row { + Some(row) => Ok(Some(CredentialAuthTokenQry::row_data(&row)?)), + None => Ok(None), + } +} + +pub async fn login( + shared: Arc, + pool: AnyPool, + data: LoginRequest, +) -> AccountServerReqResult { + let res = async { + // first verify the signature + // this step isn't really needed (security wise), + // but at least proofs the client has a valid private key. + data.account_data.public_key.verify_strict( + data.credential_auth_token.as_slice(), + &data.credential_auth_token_signature, + )?; + + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + let res = connection + .transaction(|connection| { + Box::pin(async move { + let token_data = get_and_invalidate_credential_auth_token( + &shared, + data.credential_auth_token, + connection, + ) + .await?; + + let token_data = match token_data { + Some(token_data) => token_data, + None => return Ok(LoginResponse::TokenInvalid), + }; + anyhow::ensure!( + token_data.op == CredentialAuthTokenType::Login, + "Credential auth token was not for loggin in for new credential" + ); + + enum Identifier { + Email(email_address::EmailAddress), + Steam(i64), + } + let identifier = match token_data.ty { + TokenType::Email => Identifier::Email( + email_address::EmailAddress::from_str(&token_data.identifier)?, + ), + TokenType::Steam => Identifier::Steam(token_data.identifier.parse()?), + }; + + // invalidate token + let qry = InvalidateCredentialAuthToken { + token: &data.credential_auth_token, + }; + qry.query(&shared.db.invalidate_credential_auth_token_statement) + .execute(&mut **connection) + .await?; + + // create account (if not exists) + let account_id = match &identifier { + Identifier::Email(email) => { + // query account data + let qry = AccountIdFromEmail { email }; + + let row = qry + .query(&shared.db.account_id_from_email_qry_statement) + .fetch_optional(&mut **connection) + .await?; + + row.map(|row| AccountIdFromEmail::row_data(&row)) + .transpose()? + .map(|data| data.account_id) + } + Identifier::Steam(steamid64) => { + // query account data + let qry = AccountIdFromSteam { steamid64 }; + + let row = qry + .query(&shared.db.account_id_from_steam_qry_statement) + .fetch_optional(&mut **connection) + .await?; + + row.map(|row| AccountIdFromSteam::row_data(&row)) + .transpose()? + .map(|data| data.account_id) + } + }; + + let account_id = match account_id { + Some(account_id) => account_id, + None => { + let qry = TryCreateAccount {}; + + let res = qry + .query(&shared.db.try_create_account_statement) + .execute(&mut **connection) + .await?; + + anyhow::ensure!(res.rows_affected() >= 1, "account was not created"); + + // query account data + let login_qry = AccountIdFromLastInsert {}; + let row = login_qry + .query(&shared.db.account_id_from_last_insert_qry_statement) + .fetch_one(&mut **connection) + .await?; + + let login_data = AccountIdFromLastInsert::row_data(&row)?; + + match identifier { + Identifier::Email(email) => { + let qry = LinkAccountCredentialEmail { + account_id: &login_data.account_id, + email: &email, + }; + + let res = qry + .query(&shared.db.link_credentials_email_qry_statement) + .execute(&mut **connection) + .await?; + + anyhow::ensure!( + res.rows_affected() >= 1, + "account was not created, linking email failed" + ); + } + Identifier::Steam(steamid64) => { + let qry = LinkAccountCredentialSteam { + account_id: &login_data.account_id, + steamid64: &steamid64, + }; + + let res = qry + .query(&shared.db.link_credentials_steam_qry_statement) + .execute(&mut **connection) + .await?; + + anyhow::ensure!( + res.rows_affected() >= 1, + "account was not created, linking steam failed" + ); + } + } + login_data.account_id + } + }; + + let qry = CreateSession { + account_id, + hw_id: &data.account_data.hw_id, + pub_key: data.account_data.public_key.as_bytes(), + }; + + qry.query(&shared.db.create_session_statement) + .execute(&mut **connection) + .await?; + + anyhow::Ok(LoginResponse::Success(account_id)) + }) + }) + .await?; + anyhow::Ok(res) + } + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "login".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + })?; + + match res { + LoginResponse::Success(account_id) => Ok(account_id), + LoginResponse::TokenInvalid => Err(AccountServerRequestError::LogicError( + LoginError::TokenInvalid, + )), + } +} diff --git a/src/login/mysql/account_id_from_email.sql b/src/login/mysql/account_id_from_email.sql new file mode 100644 index 0000000..e18fae7 --- /dev/null +++ b/src/login/mysql/account_id_from_email.sql @@ -0,0 +1,6 @@ +SELECT + account_id +FROM + credential_email +WHERE + email = ?; diff --git a/src/login/mysql/account_id_from_last_insert.sql b/src/login/mysql/account_id_from_last_insert.sql new file mode 100644 index 0000000..41c8135 --- /dev/null +++ b/src/login/mysql/account_id_from_last_insert.sql @@ -0,0 +1,2 @@ +SELECT + CAST(LAST_INSERT_ID() AS SIGNED) AS account_id; diff --git a/src/login/mysql/account_id_from_steam.sql b/src/login/mysql/account_id_from_steam.sql new file mode 100644 index 0000000..cb39808 --- /dev/null +++ b/src/login/mysql/account_id_from_steam.sql @@ -0,0 +1,6 @@ +SELECT + account_id +FROM + credential_steam +WHERE + steamid64 = ?; diff --git a/src/login/mysql/add_account.sql b/src/login/mysql/add_account.sql new file mode 100644 index 0000000..687463a --- /dev/null +++ b/src/login/mysql/add_account.sql @@ -0,0 +1,4 @@ +INSERT INTO + account (create_time) +VALUES + (UTC_TIMESTAMP()); diff --git a/src/login/mysql/add_session.sql b/src/login/mysql/add_session.sql new file mode 100644 index 0000000..da345fd --- /dev/null +++ b/src/login/mysql/add_session.sql @@ -0,0 +1,8 @@ +INSERT INTO + user_session ( + account_id, + pub_key, + hw_id + ) +VALUES + (?, ?, ?); diff --git a/src/login/mysql/credential_auth_token_data.sql b/src/login/mysql/credential_auth_token_data.sql new file mode 100644 index 0000000..77c193e --- /dev/null +++ b/src/login/mysql/credential_auth_token_data.sql @@ -0,0 +1,9 @@ +SELECT + credential_auth_tokens.ty, + credential_auth_tokens.identifier, + credential_auth_tokens.op +FROM + credential_auth_tokens +WHERE + credential_auth_tokens.token = ? + AND credential_auth_tokens.valid_until > UTC_TIMESTAMP(); diff --git a/src/login/mysql/invalidate_credential_auth_token.sql b/src/login/mysql/invalidate_credential_auth_token.sql new file mode 100644 index 0000000..6627750 --- /dev/null +++ b/src/login/mysql/invalidate_credential_auth_token.sql @@ -0,0 +1,4 @@ +DELETE FROM + credential_auth_tokens +WHERE + credential_auth_tokens.token = ?; diff --git a/src/login/mysql/link_credential_email.sql b/src/login/mysql/link_credential_email.sql new file mode 100644 index 0000000..8d933c4 --- /dev/null +++ b/src/login/mysql/link_credential_email.sql @@ -0,0 +1,4 @@ +INSERT INTO + credential_email (account_id, email, valid_until) +VALUES + (?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 1 YEAR)); diff --git a/src/login/mysql/link_credential_steam.sql b/src/login/mysql/link_credential_steam.sql new file mode 100644 index 0000000..8cc6cad --- /dev/null +++ b/src/login/mysql/link_credential_steam.sql @@ -0,0 +1,4 @@ +INSERT INTO + credential_steam (account_id, steamid64, valid_until) +VALUES + (?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 1 YEAR)); diff --git a/src/login/queries.rs b/src/login/queries.rs new file mode 100644 index 0000000..b91f704 --- /dev/null +++ b/src/login/queries.rs @@ -0,0 +1,272 @@ +use std::str::FromStr; + +use account_sql::query::Query; +use accounts_shared::client::credential_auth_token::CredentialAuthTokenOperation; +use accounts_shared::client::login::CredentialAuthToken; +use accounts_shared::client::machine_id::MachineUid; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Row; +use sqlx::Statement; + +use crate::types::TokenType; + +pub struct CredentialAuthTokenQry<'a> { + pub token: &'a CredentialAuthToken, +} + +pub struct CredentialAuthTokenData { + pub ty: TokenType, + pub op: CredentialAuthTokenOperation, + pub identifier: String, +} + +#[async_trait] +impl<'a> Query for CredentialAuthTokenQry<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/credential_auth_token_data.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.token.as_slice()) + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(CredentialAuthTokenData { + ty: TokenType::from_str( + row.try_get("ty") + .map_err(|err| anyhow!("Failed get column ty: {err}"))?, + )?, + identifier: row + .try_get("identifier") + .map_err(|err| anyhow!("Failed get column identifier: {err}"))?, + op: CredentialAuthTokenOperation::from_str( + row.try_get("op") + .map_err(|err| anyhow!("Failed get column op: {err}"))?, + )?, + }) + } +} + +pub struct InvalidateCredentialAuthToken<'a> { + pub token: &'a CredentialAuthToken, +} + +#[async_trait] +impl<'a> Query<()> for InvalidateCredentialAuthToken<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/invalidate_credential_auth_token.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.token.as_slice()) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct TryCreateAccount {} + +#[async_trait] +impl Query<()> for TryCreateAccount { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/add_account.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query() + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct LinkAccountCredentialEmail<'a> { + pub account_id: &'a AccountId, + pub email: &'a email_address::EmailAddress, +} + +#[async_trait] +impl<'a> Query<()> for LinkAccountCredentialEmail<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/link_credential_email.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement + .query() + .bind(self.account_id) + .bind(self.email.as_str()) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct LinkAccountCredentialSteam<'a> { + pub account_id: &'a AccountId, + pub steamid64: &'a i64, +} + +#[async_trait] +impl<'a> Query<()> for LinkAccountCredentialSteam<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/link_credential_steam.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.account_id).bind(self.steamid64) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct AccountData { + pub account_id: AccountId, +} + +pub struct AccountIdFromLastInsert {} + +#[async_trait] +impl Query for AccountIdFromLastInsert { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/account_id_from_last_insert.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query() + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(AccountData { + account_id: row + .try_get("account_id") + .map_err(|err| anyhow!("Failed get column account id: {err}"))?, + }) + } +} + +pub struct AccountIdFromEmail<'a> { + pub email: &'a email_address::EmailAddress, +} + +#[async_trait] +impl<'a> Query for AccountIdFromEmail<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/account_id_from_email.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.email.as_str()) + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(AccountData { + account_id: row + .try_get("account_id") + .map_err(|err| anyhow!("Failed get column account id: {err}"))?, + }) + } +} + +pub struct AccountIdFromSteam<'a> { + pub steamid64: &'a i64, +} + +#[async_trait] +impl<'a> Query for AccountIdFromSteam<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/account_id_from_steam.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.steamid64) + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(AccountData { + account_id: row + .try_get("account_id") + .map_err(|err| anyhow!("Failed get column account id: {err}"))?, + }) + } +} + +pub struct CreateSession<'a> { + pub account_id: AccountId, + pub pub_key: &'a [u8; ed25519_dalek::PUBLIC_KEY_LENGTH], + pub hw_id: &'a MachineUid, +} + +#[async_trait] +impl<'a> Query<()> for CreateSession<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/add_session.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement + .query() + .bind(self.account_id) + .bind(self.pub_key.as_slice()) + .bind(self.hw_id.as_slice()) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/logout.rs b/src/logout.rs new file mode 100644 index 0000000..4631e45 --- /dev/null +++ b/src/logout.rs @@ -0,0 +1,61 @@ +pub mod queries; + +use std::sync::Arc; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, + }, + client::logout::LogoutRequest, +}; +use axum::Json; +use sqlx::{Acquire, AnyPool}; + +use crate::shared::{Shared, CERT_MAX_AGE_DELTA, CERT_MIN_AGE_DELTA}; + +use self::queries::RemoveSession; + +pub async fn logout_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json( + logout(shared, pool, data) + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "logout".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} + +pub async fn logout(shared: Arc, pool: AnyPool, data: LogoutRequest) -> anyhow::Result<()> { + data.account_data + .public_key + .verify_strict(data.time_stamp.to_string().as_bytes(), &data.signature)?; + let now = chrono::Utc::now(); + let delta = now.signed_duration_since(data.time_stamp); + anyhow::ensure!( + delta < CERT_MAX_AGE_DELTA && delta > CERT_MIN_AGE_DELTA, + "time stamp was not in a valid time frame." + ); + + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + // remove this session + let qry = RemoveSession { + pub_key: data.account_data.public_key.as_bytes(), + hw_id: &data.account_data.hw_id, + }; + + qry.query(&shared.db.logout_statement) + .execute(connection) + .await?; + + Ok(()) +} diff --git a/src/logout/mysql/rem_session.sql b/src/logout/mysql/rem_session.sql new file mode 100644 index 0000000..73fdba5 --- /dev/null +++ b/src/logout/mysql/rem_session.sql @@ -0,0 +1,5 @@ +DELETE FROM + user_session +WHERE + user_session.pub_key = ? + AND user_session.hw_id = ?; diff --git a/src/logout/queries.rs b/src/logout/queries.rs new file mode 100644 index 0000000..70e2b96 --- /dev/null +++ b/src/logout/queries.rs @@ -0,0 +1,35 @@ +use account_sql::query::Query; +use accounts_shared::client::machine_id::MachineUid; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +pub struct RemoveSession<'a> { + pub pub_key: &'a [u8; 32], + pub hw_id: &'a MachineUid, +} + +#[async_trait] +impl<'a> Query<()> for RemoveSession<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/rem_session.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement + .query() + .bind(self.pub_key.as_slice()) + .bind(self.hw_id.as_slice()) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/logout_all.rs b/src/logout_all.rs new file mode 100644 index 0000000..5bf16cc --- /dev/null +++ b/src/logout_all.rs @@ -0,0 +1,109 @@ +pub mod queries; + +use std::sync::Arc; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, + }, + client::logout_all::{IgnoreSession, LogoutAllRequest}, +}; +use axum::Json; +use sqlx::{Acquire, AnyPool, Connection}; + +use crate::{ + account_token::queries::{AccountTokenQry, InvalidateAccountToken}, + shared::{Shared, CERT_MAX_AGE_DELTA, CERT_MIN_AGE_DELTA}, + types::AccountTokenType, +}; + +use self::queries::RemoveSessionsExcept; + +pub async fn logout_all_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json(logout_all(shared, pool, data).await.map_err(|err| { + AccountServerRequestError::Unexpected { + target: "logout_all".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + } + })) +} + +pub async fn logout_all( + shared: Arc, + pool: AnyPool, + data: LogoutAllRequest, +) -> anyhow::Result<()> { + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + connection + .transaction(|connection| { + Box::pin(async move { + // token data + let acc_token_qry = AccountTokenQry { + token: &data.account_token, + }; + + let row = acc_token_qry + .query(&shared.db.account_token_qry_statement) + .fetch_one(&mut **connection) + .await?; + + let token_data = AccountTokenQry::row_data(&row)?; + + // invalidate token + let qry = InvalidateAccountToken { + token: &data.account_token, + }; + qry.query(&shared.db.invalidate_account_token_statement) + .execute(&mut **connection) + .await?; + + anyhow::ensure!( + token_data.ty == AccountTokenType::LogoutAll, + "Account token was not for logout all operation." + ); + let account_id = token_data.account_id; + + let validate_session = |ignore_session: IgnoreSession| { + ignore_session.account_data.public_key.verify_strict( + ignore_session.time_stamp.to_string().as_bytes(), + &ignore_session.signature, + )?; + let now = chrono::Utc::now(); + let delta = now.signed_duration_since(ignore_session.time_stamp); + anyhow::ensure!( + delta < CERT_MAX_AGE_DELTA && delta > CERT_MIN_AGE_DELTA, + "time stamp was not in a valid time frame." + ); + anyhow::Ok(ignore_session.account_data) + }; + let session_data = data.ignore_session.and_then(|ignore_session| { + // if validating fails, still log out all sessions, since that is less important + validate_session(ignore_session).ok() + }); + + // remove all sessions + let qry = RemoveSessionsExcept { + account_id: &account_id, + session_data: &session_data, + }; + + qry.query(&shared.db.remove_sessions_except_statement) + .execute(&mut **connection) + .await?; + + anyhow::Ok(()) + }) + }) + .await?; + + Ok(()) +} diff --git a/src/logout_all/mysql/rem_sessions_except.sql b/src/logout_all/mysql/rem_sessions_except.sql new file mode 100644 index 0000000..bc0c5f2 --- /dev/null +++ b/src/logout_all/mysql/rem_sessions_except.sql @@ -0,0 +1,11 @@ +DELETE FROM + user_session +WHERE + user_session.account_id = ? + AND ( + ? IS NULL + OR ( + user_session.pub_key <> ? + AND user_session.hw_id <> ? + ) + ); diff --git a/src/logout_all/queries.rs b/src/logout_all/queries.rs new file mode 100644 index 0000000..12d20df --- /dev/null +++ b/src/logout_all/queries.rs @@ -0,0 +1,43 @@ +use account_sql::query::Query; +use accounts_shared::client::account_data::AccountDataForServer; +use accounts_types::account_id::AccountId; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +pub struct RemoveSessionsExcept<'a> { + pub account_id: &'a AccountId, + pub session_data: &'a Option, +} + +#[async_trait] +impl<'a> Query<()> for RemoveSessionsExcept<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/rem_sessions_except.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + let (key, hwid) = self + .session_data + .as_ref() + .map(|data| (data.public_key.as_bytes().as_slice(), data.hw_id.as_slice())) + .unzip(); + statement + .query() + .bind(self.account_id) + .bind(key) + .bind(key) + .bind(hwid) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..e4f2ea5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,895 @@ -fn main() { - println!("Hello, world!"); +//! This is the http + db implementation for the account server. + +#![deny(missing_docs)] +#![deny(warnings)] +#![deny(clippy::nursery)] +#![deny(clippy::all)] + +pub(crate) mod account_token; +mod certs; +pub(crate) mod credential_auth_token; +pub(crate) mod db; +pub(crate) mod delete; +pub(crate) mod email; +pub(crate) mod login; +mod logout; +pub(crate) mod setup; +pub(crate) mod shared; +pub(crate) mod sign; +pub(crate) mod steam; +pub(crate) mod update; + +pub(crate) mod email_limit; +pub(crate) mod ip_limit; + +pub(crate) mod logout_all; + +mod account_info; +mod file_watcher; +mod link_credential; +#[cfg(test)] +mod tests; +mod types; +mod unlink_credential; + +use account_info::{account_info_request, queries::AccountInfo}; +use account_sql::query::Query; +use account_token::{ + account_token_email, account_token_steam, + queries::{ + AccountTokenQry, AddAccountTokenEmail, AddAccountTokenSteam, InvalidateAccountToken, + }, +}; +use accounts_shared::account_server::{ + errors::AccountServerRequestError, result::AccountServerReqResult, +}; +use anyhow::anyhow; +use axum::{extract::DefaultBodyLimit, response::IntoResponse, Json, Router}; +use certs::{ + certs_request, generate_key_and_cert, get_certs, + queries::{AddCert, GetCerts}, + store_cert, PrivateKeys, +}; +use clap::{command, parser::ValueSource, Arg, ArgAction}; +use credential_auth_token::{ + credential_auth_token_email, credential_auth_token_steam, queries::AddCredentialAuthToken, +}; +use db::DbConnectionShared; +use delete::{delete_request, queries::RemoveAccount}; +use either::Either; +use email::EmailShared; +use ip_limit::{ip_deny_layer, IpDenyList}; +use link_credential::{ + link_credential_request, + queries::{UnlinkCredentialEmail, UnlinkCredentialSteam}, +}; +use login::{ + login_request, + queries::{ + AccountIdFromEmail, AccountIdFromLastInsert, AccountIdFromSteam, CreateSession, + CredentialAuthTokenQry, InvalidateCredentialAuthToken, LinkAccountCredentialEmail, + LinkAccountCredentialSteam, TryCreateAccount, + }, +}; +use logout::{logout_request, queries::RemoveSession}; +use logout_all::{logout_all_request, queries::RemoveSessionsExcept}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use shared::Shared; +use sign::{queries::AuthAttempt, sign_request}; +use sqlx::{any::AnyPoolOptions, mysql::MySqlConnectOptions, Any, AnyPool, Pool}; +use std::{ + net::SocketAddr, + num::NonZeroU32, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime}, +}; +use steam::{SteamShared, OFFICIAL_STEAM_AUTH_URL}; +use tokio::net::{TcpListener, TcpSocket}; +use tower::ServiceBuilder; +use tower_governor::{ + governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer, +}; +use unlink_credential::{ + queries::{UnlinkCredentialByEmail, UnlinkCredentialBySteam}, + unlink_credential_request, +}; +use update::{ + handle_watchers, + queries::{CleanupAccountTokens, CleanupCerts, CleanupCredentialAuthTokens}, + update, +}; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DbDetails { + host: String, + port: u16, + database: String, + username: String, + password: String, + ca_cert_path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct HttpServerDetails { + port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EmailDetails { + relay: String, + relay_port: u16, + username: String, + password: String, + /// The name of the sender of all emails + /// e.g. `accounts@mydomain.org` + email_from: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SteamDetails { + auth_url: Option, + publisher_auth_key: String, + app_id: u32, + identify: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LimiterValues { + /// time until another attempt is allowed + time_until_another_attempt: Duration, + /// so many attempts are allowed initially + initial_request_count: NonZeroU32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LimiterSettings { + credential_auth_tokens: LimiterValues, + credential_auth_tokens_secret: LimiterValues, + account_tokens: LimiterValues, + account_tokens_secret: LimiterValues, + login: LimiterValues, + link_credential: LimiterValues, + unlink_credential: LimiterValues, + delete: LimiterValues, + logout_all: LimiterValues, + logout: LimiterValues, + account_info: LimiterValues, +} + +impl Default for LimiterSettings { + fn default() -> Self { + Self { + credential_auth_tokens: LimiterValues { + // once per day + time_until_another_attempt: Duration::from_secs(60 * 60 * 24), + // one request total + initial_request_count: NonZeroU32::new(1).unwrap(), + }, + credential_auth_tokens_secret: LimiterValues { + // once per hour + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total, since the secret is handled by the + // account server or related software + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + account_tokens: LimiterValues { + // once per day + time_until_another_attempt: Duration::from_secs(60 * 60 * 24), + // one request total + initial_request_count: NonZeroU32::new(1).unwrap(), + }, + account_tokens_secret: LimiterValues { + // once per hour + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total, since the secret is handled by the + // account server or related software + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + login: LimiterValues { + // once per day + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + link_credential: LimiterValues { + // once per hour + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + unlink_credential: LimiterValues { + // once per hour + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + delete: LimiterValues { + // once per hour + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + logout: LimiterValues { + // once per hour + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + logout_all: LimiterValues { + // once per hour + time_until_another_attempt: Duration::from_secs(60 * 60), + // 5 request total + initial_request_count: NonZeroU32::new(5).unwrap(), + }, + account_info: LimiterValues { + // once per minute + time_until_another_attempt: Duration::from_secs(60), + // 3 request total + initial_request_count: NonZeroU32::new(3).unwrap(), + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Details { + db: DbDetails, + http: HttpServerDetails, + email: EmailDetails, + steam: SteamDetails, + limitter: LimiterSettings, +} + +pub(crate) async fn prepare_db(details: &DbDetails) -> anyhow::Result> { + let is_localhost = + details.host == "localhost" || details.host == "127.0.0.1" || details.host == "::1"; + Ok(AnyPoolOptions::new() + .max_connections(200) + .connect_with( + MySqlConnectOptions::new() + .charset("utf8mb4") + .host(&details.host) + .port(details.port) + .database(&details.database) + .username(&details.username) + .password(&details.password) + .ssl_mode(if !is_localhost { + sqlx::mysql::MySqlSslMode::Required + } else { + sqlx::mysql::MySqlSslMode::Preferred + }) + .ssl_ca(&details.ca_cert_path) + .into(), + ) + .await?) +} + +pub(crate) async fn prepare_statements(pool: &Pool) -> anyhow::Result { + let mut connection = pool.acquire().await?; + + // now prepare the statements + let credential_auth_token_statement = AddCredentialAuthToken::prepare(&mut connection).await?; + let credential_auth_token_qry_statement = + CredentialAuthTokenQry::prepare(&mut connection).await?; + let invalidate_credential_auth_token_statement = + InvalidateCredentialAuthToken::prepare(&mut connection).await?; + let try_create_account_statement = TryCreateAccount::prepare(&mut connection).await?; + let account_id_from_last_insert_qry_statement = + AccountIdFromLastInsert::prepare(&mut connection).await?; + let account_id_from_email_qry_statement = AccountIdFromEmail::prepare(&mut connection).await?; + let account_id_from_steam_qry_statement = AccountIdFromSteam::prepare(&mut connection).await?; + let link_credentials_email_qry_statement = + LinkAccountCredentialEmail::prepare(&mut connection).await?; + let link_credentials_steam_qry_statement = + LinkAccountCredentialSteam::prepare(&mut connection).await?; + let create_session_statement = CreateSession::prepare(&mut connection).await?; + let logout_statement = RemoveSession::prepare(&mut connection).await?; + let auth_attempt_statement = AuthAttempt::prepare(&mut connection).await?; + let account_token_email_statement = AddAccountTokenEmail::prepare(&mut connection).await?; + let account_token_steam_statement = AddAccountTokenSteam::prepare(&mut connection).await?; + let account_token_qry_statement = AccountTokenQry::prepare(&mut connection).await?; + let invalidate_account_token_statement = + InvalidateAccountToken::prepare(&mut connection).await?; + let remove_sessions_except_statement = RemoveSessionsExcept::prepare(&mut connection).await?; + let remove_account_statement = RemoveAccount::prepare(&mut connection).await?; + let add_cert_statement = AddCert::prepare(&mut connection).await?; + let get_certs_statement = GetCerts::prepare(&mut connection).await?; + let cleanup_credential_auth_tokens_statement = + CleanupCredentialAuthTokens::prepare(&mut connection).await?; + let cleanup_account_tokens_statement = CleanupAccountTokens::prepare(&mut connection).await?; + let cleanup_certs_statement = CleanupCerts::prepare(&mut connection).await?; + let unlink_credential_email_statement = UnlinkCredentialEmail::prepare(&mut connection).await?; + let unlink_credential_steam_statement = UnlinkCredentialSteam::prepare(&mut connection).await?; + let unlink_credential_by_email_statement = + UnlinkCredentialByEmail::prepare(&mut connection).await?; + let unlink_credential_by_steam_statement = + UnlinkCredentialBySteam::prepare(&mut connection).await?; + let account_info = AccountInfo::prepare(&mut connection).await?; + + Ok(DbConnectionShared { + credential_auth_token_statement, + credential_auth_token_qry_statement, + invalidate_credential_auth_token_statement, + try_create_account_statement, + account_id_from_last_insert_qry_statement, + account_id_from_email_qry_statement, + account_id_from_steam_qry_statement, + link_credentials_email_qry_statement, + link_credentials_steam_qry_statement, + create_session_statement, + logout_statement, + auth_attempt_statement, + account_token_email_statement, + account_token_steam_statement, + account_token_qry_statement, + invalidate_account_token_statement, + remove_sessions_except_statement, + remove_account_statement, + add_cert_statement, + get_certs_statement, + cleanup_credential_auth_tokens_statement, + cleanup_account_tokens_statement, + cleanup_certs_statement, + unlink_credential_email_statement, + unlink_credential_steam_statement, + unlink_credential_by_email_statement, + unlink_credential_by_steam_statement, + account_info, + }) +} + +pub(crate) async fn prepare_email(details: &EmailDetails) -> anyhow::Result { + EmailShared::new( + &details.relay, + details.relay_port, + &details.email_from, + &details.username, + &details.password, + ) + .await +} + +pub(crate) fn prepare_steam(details: &SteamDetails) -> anyhow::Result { + SteamShared::new( + details + .auth_url + .clone() + .unwrap_or_else(|| OFFICIAL_STEAM_AUTH_URL.try_into().unwrap()), + &details.publisher_auth_key, + details.identify.as_deref(), + details.app_id, + ) +} + +pub(crate) async fn prepare_http( + details: &HttpServerDetails, + db: DbConnectionShared, + email: EmailShared, + steam: SteamShared, + pool: &Pool, + settings: &LimiterSettings, +) -> anyhow::Result<(TcpListener, Router, Arc)> { + let keys = tokio::fs::read("signing_keys.json") + .await + .map_err(|err| anyhow!(err)) + .and_then(|key| serde_json::from_slice::(&key).map_err(|err| anyhow!(err))); + + let keys = if let Ok(keys) = keys { + keys + } else { + let (key1, cert1) = generate_key_and_cert(true)?; + store_cert(&db, pool, &cert1).await?; + + let (key2, cert2) = generate_key_and_cert(false)?; + store_cert(&db, pool, &cert2).await?; + + let res = PrivateKeys { + current_key: key1, + current_cert: cert1, + next_key: key2, + next_cert: cert2, + }; + + tokio::fs::write("signing_keys.json", serde_json::to_vec(&res)?).await?; + + res + }; + + let certs = get_certs(&db, pool).await?; + + let shared = Arc::new(Shared { + db, + email, + steam, + ip_ban_list: Arc::new(RwLock::new(IpDenyList::load_from_file().await)), + signing_keys: Arc::new(RwLock::new(Arc::new(keys))), + cert_chain: Arc::new(RwLock::new(Arc::new(certs))), + account_tokens_email: Arc::new(RwLock::new(Arc::new( + EmailShared::load_email_template("account_tokens.html") + .await + .unwrap_or_else(|_| { + "

Hello %SUBJECT%,

\n\ +

Please use the following token to verify your action:

\n\ +
%CODE%
" + .to_string() + }), + ))), + credential_auth_tokens_email: Arc::new(RwLock::new(Arc::new( + EmailShared::load_email_template("credential_auth_tokens.html") + .await + .unwrap_or_else(|_| { + "

Hello %SUBJECT%,

\n\ +

Please use the following token to verify your action:

\n\ +
%CODE%
" + .to_string() + }), + ))), + }); + + // prepare socket + let tcp_socket = TcpSocket::new_v4()?; + tcp_socket.set_reuseaddr(true)?; + tcp_socket.bind(format!("127.0.0.1:{}", details.port).parse()?)?; + + let listener = tcp_socket.listen(1024)?; + + // build http server + let layer = |limiter: &LimiterValues| { + anyhow::Ok( + ServiceBuilder::new().layer(GovernorLayer { + config: Arc::new( + GovernorConfigBuilder::default() + .key_extractor(SmartIpKeyExtractor) + .period(limiter.time_until_another_attempt) + .burst_size(limiter.initial_request_count.get()) + .error_handler(|err| match err { + tower_governor::GovernorError::TooManyRequests { .. } => { + Json(AccountServerReqResult::<(), ()>::Err( + AccountServerRequestError::RateLimited(err.to_string()), + )) + .into_response() + } + tower_governor::GovernorError::UnableToExtractKey + | tower_governor::GovernorError::Other { .. } => { + Json(AccountServerReqResult::<(), ()>::Err( + AccountServerRequestError::Other(err.to_string()), + )) + .into_response() + } + }) + .finish() + .ok_or_else(|| anyhow!("Could not create governor config."))?, + ), + }), + ) + }; + + // Crendential auth tokens + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let token_email = axum::Router::new() + .route( + "/email", + axum::routing::post(move |payload: Json<_>| { + credential_auth_token_email(shared_clone, pool_clone, false, payload) + }), + ) + .layer(layer(&settings.credential_auth_tokens)?); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let token_steam = axum::Router::new() + .route( + "/steam", + axum::routing::post(move |payload: Json<_>| { + credential_auth_token_steam(shared_clone, pool_clone, false, payload) + }), + ) + .layer(layer(&settings.credential_auth_tokens)?); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let token_email_secret = axum::Router::new() + .route( + "/email-secret", + axum::routing::post(move |payload: Json<_>| { + credential_auth_token_email(shared_clone, pool_clone, true, payload) + }), + ) + .layer(layer(&settings.credential_auth_tokens_secret)?); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let token_steam_secret = axum::Router::new() + .route( + "/steam-secret", + axum::routing::post(move |payload: Json<_>| { + credential_auth_token_steam(shared_clone, pool_clone, true, payload) + }), + ) + .layer(layer(&settings.credential_auth_tokens_secret)?); + let mut app = axum::Router::new(); + app = app.nest( + "/token", + Router::new() + .merge(token_email) + .merge(token_steam) + .merge(token_email_secret) + .merge(token_steam_secret), + ); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let account_token_by_email = axum::Router::new() + .route( + "/email", + axum::routing::post(move |payload: Json<_>| { + account_token_email(shared_clone, pool_clone, false, payload) + }), + ) + .layer(layer(&settings.account_tokens)?); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let account_token_secret_email = axum::Router::new() + .route( + "/email-secret", + axum::routing::post(move |payload: Json<_>| { + account_token_email(shared_clone, pool_clone, true, payload) + }), + ) + .layer(layer(&settings.account_tokens_secret)?); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let account_token_by_steam = axum::Router::new() + .route( + "/steam", + axum::routing::post(move |payload: Json<_>| { + account_token_steam(shared_clone, pool_clone, false, payload) + }), + ) + .layer(layer(&settings.account_tokens)?); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + let account_token_secret_steam = axum::Router::new() + .route( + "/steam-secret", + axum::routing::post(move |payload: Json<_>| { + account_token_steam(shared_clone, pool_clone, true, payload) + }), + ) + .layer(layer(&settings.account_tokens_secret)?); + app = app.nest( + "/account-token", + Router::new() + .merge(account_token_by_email) + .merge(account_token_secret_email) + .merge(account_token_by_steam) + .merge(account_token_secret_steam), + ); + // Actual login + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.merge( + axum::Router::new().route( + "/login", + axum::routing::post(move |payload: Json<_>| { + login_request(shared_clone, pool_clone, payload) + }) + .layer(layer(&settings.login)?), + ), + ); + // Link credential + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.merge( + axum::Router::new().route( + "/link-credential", + axum::routing::post(move |payload: Json<_>| { + link_credential_request(shared_clone, pool_clone, payload) + }) + .layer(layer(&settings.link_credential)?), + ), + ); + // Unlink credential + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.merge( + axum::Router::new().route( + "/unlink-credential", + axum::routing::post(move |qry: Json<_>| { + unlink_credential_request(shared_clone, pool_clone, qry) + }) + .layer(layer(&settings.unlink_credential)?), + ), + ); + // Delete account + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.merge( + axum::Router::new().route( + "/delete", + axum::routing::post(move |qry: Json<_>| delete_request(shared_clone, pool_clone, qry)) + .layer(layer(&settings.delete)?), + ), + ); + // Logout all + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.merge( + axum::Router::new().route( + "/logout-all", + axum::routing::post(move |qry: Json<_>| { + logout_all_request(shared_clone, pool_clone, qry) + }) + .layer(layer(&settings.logout_all)?), + ), + ); + // Logout + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.merge( + axum::Router::new().route( + "/logout", + axum::routing::post(move |qry: Json<_>| logout_request(shared_clone, pool_clone, qry)) + .layer(layer(&settings.logout)?), + ), + ); + // account info + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.merge( + axum::Router::new().route( + "/account-info", + axum::routing::post(move |qry: Json<_>| { + account_info_request(shared_clone, pool_clone, qry) + }) + .layer(layer(&settings.account_info)?), + ), + ); + let shared_clone = shared.clone(); + let pool_clone = pool.clone(); + app = app.route( + "/sign", + axum::routing::post(move |payload: Json<_>| { + sign_request(shared_clone, pool_clone, payload) + }), + ); + let shared_clone = shared.clone(); + app = app.route( + "/certs", + axum::routing::get(move || certs_request(shared_clone)), + ); + app = app.route("/ping", axum::routing::get(|| async { Json("pong") })); + // 16 KiB limit should be enough for all requests + let request_size = DefaultBodyLimit::max(1024 * 16); + app = app + .layer(request_size) + .layer(axum::middleware::from_fn_with_state( + shared.ip_ban_list.clone(), + ip_deny_layer, + )); + + Ok((listener, app, shared)) +} + +pub(crate) async fn prepare( + details: &Details, +) -> anyhow::Result<(TcpListener, Router, Pool, Arc)> { + // first connect to the database + let pool = prepare_db(&details.db).await?; + + let db = prepare_statements(&pool).await?; + let email = prepare_email(&details.email).await?; + let steam = prepare_steam(&details.steam)?; + let (listener, app, shared) = + prepare_http(&details.http, db, email, steam, &pool, &details.limitter).await?; + + Ok((listener, app, pool, shared)) +} + +/// Returns the time the impl should wait before calling this function again. +/// Returns `Either::Right` if a new cert was created, else `Either::Left`. +pub(crate) async fn generate_new_signing_keys_impl( + pool: &AnyPool, + shared: &Arc, + now: SystemTime, + default_check_key_time: Duration, + err_check_key_time: Duration, + validy_extra_offset: Duration, +) -> Either { + // once per day check if a new signing key should be created + let mut next_sleep_time = Either::Left(default_check_key_time); + let err_check_key_time = Either::Left(err_check_key_time); + + let check_keys = shared.signing_keys.read().clone(); + if now + validy_extra_offset + >= check_keys + .current_cert + .tbs_certificate + .validity + .not_after + .to_system_time() + { + // create a new key & cert, switch next key to current + if let Ok((key, cert)) = generate_key_and_cert(false) { + let store_res = store_cert(&shared.db, pool, &cert).await; + if store_res.is_err() { + next_sleep_time = err_check_key_time; + } else if let Ok(certs) = get_certs(&shared.db, pool).await { + let cur_keys = shared.signing_keys.read().clone(); + let new_keys = Arc::new(PrivateKeys { + current_key: cur_keys.next_key.clone(), + current_cert: cur_keys.next_cert.clone(), + next_key: key, + next_cert: cert, + }); + if let Ok(val) = serde_json::to_vec(new_keys.as_ref()) { + if tokio::fs::write("signing_keys.json", val).await.is_ok() { + *shared.cert_chain.write() = Arc::new(certs); + *shared.signing_keys.write() = new_keys; + next_sleep_time = Either::Right(default_check_key_time); + } else { + next_sleep_time = err_check_key_time; + } + } else { + next_sleep_time = err_check_key_time; + } + } else { + next_sleep_time = err_check_key_time; + } + } else { + next_sleep_time = err_check_key_time; + } + } + next_sleep_time +} + +pub(crate) async fn generate_new_signing_keys(pool: &AnyPool, shared: &Arc) -> Duration { + match generate_new_signing_keys_impl( + pool, + shared, + SystemTime::now(), + Duration::from_secs(60 * 60 * 24), + Duration::from_secs(60 * 60 * 2), + Duration::from_secs(60 * 60 * 24 * 7), + ) + .await + { + Either::Left(r) | Either::Right(r) => r, + } +} + +async fn regenerate_signing_keys_and_certs(pool: AnyPool, shared: Arc) -> ! { + loop { + let next_sleep_time = generate_new_signing_keys(&pool, &shared).await; + + tokio::time::sleep(next_sleep_time).await; + + // get latest certs + if let Ok(certs) = get_certs(&shared.db, &pool).await { + *shared.cert_chain.write() = Arc::new(certs); + } + } +} + +// https://github.com/tokio-rs/tokio/issues/5616 +#[allow(clippy::redundant_pub_crate)] +pub(crate) async fn run( + listener: TcpListener, + app: Router, + pool: AnyPool, + shared: Arc, + handle_updates: bool, +) -> anyhow::Result<()> { + let pool_clone = pool.clone(); + let shared_clone = shared.clone(); + let shared_watchers = shared.clone(); + let app = app.into_make_service_with_connect_info::(); + tokio::select!( + err = async move { axum::serve(listener, app).await } => { + err?; + }, + _ = async move { + regenerate_signing_keys_and_certs(pool, shared).await + }, if handle_updates => {} + _ = async move { + update(pool_clone, shared_clone).await + }, if handle_updates => {} + _ = async move { + handle_watchers(shared_watchers).await + }, if handle_updates => {} + ); + Ok(()) +} + +#[tokio::main] +async fn main() { + if std::env::var("RUST_LOG").is_err() { + // rust nightly compatibility + #[allow(unused_unsafe)] + unsafe { + std::env::set_var("RUST_LOG", "info") + }; + } + env_logger::init(); + + let mut cmd = command!() + .about("The account server using http & mysql.") + .arg( + Arg::new("setup") + .long("setup") + .help("Setup the account server, e.g. fill the mysql tables.") + .required(false) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("cleanup") + .long("cleanup") + .help("Cleanup the account server, e.g. remove the mysql tables.") + .required(false) + .action(ArgAction::SetTrue), + ); + cmd.build(); + let m = cmd.get_matches(); + + let print_settings_err = || { + log::error!( + "a settings.json looks like this\n{}", + serde_json::to_string_pretty(&Details { + db: DbDetails { + host: "localhost".to_string(), + port: 3306, + database: "ddnet_accounts".to_string(), + username: "user".to_string(), + password: "password".to_string(), + ca_cert_path: "/etc/mysql/ssl/ca-cert.pem".into() + }, + http: HttpServerDetails { port: 443 }, + email: EmailDetails { + relay: "emails.localhost".to_string(), + relay_port: 465, + username: "account".to_string(), + password: "email-password".to_string(), + email_from: "account@localhost".to_string(), + }, + steam: SteamDetails { + auth_url: None, + publisher_auth_key: "publisher_auth_key".into(), + app_id: 123, + identify: None + }, + limitter: Default::default() + }) + .unwrap() + ) + }; + + let Ok(cfg) = tokio::fs::read("settings.json").await else { + log::error!("no settings.json found, please create one."); + print_settings_err(); + + panic!("failed to find settings.json, see log for more information"); + }; + + let Ok(details) = serde_json::from_slice::
(&cfg) else { + log::error!("settings.json was invalid."); + print_settings_err(); + + panic!("settings were not a valid json file, see log for more information"); + }; + + if m.value_source("setup") + .is_some_and(|s| matches!(s, ValueSource::CommandLine)) + { + let pool = prepare_db(&details.db).await.unwrap(); + setup::setup(&pool).await.unwrap(); + } else if m + .value_source("cleanup") + .is_some_and(|s| matches!(s, ValueSource::CommandLine)) + { + let pool = prepare_db(&details.db).await.unwrap(); + setup::delete(&pool).await.unwrap(); + } else { + let (listener, app, pool, shared) = prepare(&details).await.unwrap(); + run(listener, app, pool, shared, true).await.unwrap(); + } } diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..1c7b30a --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,133 @@ +use account_sql::version::get_version; +use account_sql::version::set_version; +use sqlx::Acquire; +use sqlx::AnyConnection; +use sqlx::Connection; +use sqlx::Executor; +use sqlx::Statement; + +const VERSION_NAME: &str = "account-server"; + +async fn setup_version1_mysql(con: &mut AnyConnection) -> anyhow::Result<()> { + // first create all statements (syntax check) + let account = con.prepare(include_str!("setup/mysql/account.sql")).await?; + let credential_email = con + .prepare(include_str!("setup/mysql/credential_email.sql")) + .await?; + let credential_steam = con + .prepare(include_str!("setup/mysql/credential_steam.sql")) + .await?; + let credential_auth_tokens = con + .prepare(include_str!("setup/mysql/credential_auth_tokens.sql")) + .await?; + let account_tokens = con + .prepare(include_str!("setup/mysql/account_tokens.sql")) + .await?; + let session = con.prepare(include_str!("setup/mysql/session.sql")).await?; + let certs = con.prepare(include_str!("setup/mysql/certs.sql")).await?; + + // afterwards actually create tables + account.query().execute(&mut *con).await?; + credential_email.query().execute(&mut *con).await?; + credential_steam.query().execute(&mut *con).await?; + credential_auth_tokens.query().execute(&mut *con).await?; + account_tokens.query().execute(&mut *con).await?; + session.query().execute(&mut *con).await?; + certs.query().execute(&mut *con).await?; + + set_version(con, VERSION_NAME, 1).await?; + + Ok(()) +} + +pub async fn setup_version1(con: &mut AnyConnection) -> anyhow::Result<()> { + match con.kind() { + sqlx::any::AnyKind::MySql => setup_version1_mysql(con).await, + } +} + +pub async fn setup(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + tokio::fs::create_dir_all("config").await?; + + let mut pool_con = pool.acquire().await?; + let con = pool_con.acquire().await?; + + con.transaction(|con| { + Box::pin(async move { + let version = get_version(con, VERSION_NAME).await?; + if version < 1 { + setup_version1(&mut *con).await?; + } + + anyhow::Ok(()) + }) + }) + .await +} + +async fn delete_mysql(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + let mut pool_con = pool.acquire().await?; + let con = pool_con.acquire().await?; + + // first create all statements (syntax check) + // delete in reverse order to creating + let session = con + .prepare(include_str!("setup/mysql/delete/session.sql")) + .await?; + let credential_auth_tokens = con + .prepare(include_str!( + "setup/mysql/delete/credential_auth_tokens.sql" + )) + .await?; + let account_tokens = con + .prepare(include_str!("setup/mysql/delete/account_tokens.sql")) + .await?; + let credential_steam = con + .prepare(include_str!("setup/mysql/delete/credential_steam.sql")) + .await?; + let credential_email = con + .prepare(include_str!("setup/mysql/delete/credential_email.sql")) + .await?; + let account = con + .prepare(include_str!("setup/mysql/delete/account.sql")) + .await?; + let certs = con + .prepare(include_str!("setup/mysql/delete/certs.sql")) + .await?; + + // afterwards actually drop tables + let session = session.query().execute(&mut *con).await; + let credential_auth_tokens = credential_auth_tokens.query().execute(&mut *con).await; + let account_tokens = account_tokens.query().execute(&mut *con).await; + let credential_steam = credential_steam.query().execute(&mut *con).await; + let credential_email = credential_email.query().execute(&mut *con).await; + let account = account.query().execute(&mut *con).await; + let certs = certs.query().execute(&mut *con).await; + + let _ = set_version(con, VERSION_NAME, 0).await; + + // handle errors at once + session + .and(credential_auth_tokens) + .and(account_tokens) + .and(credential_steam) + .and(credential_email) + .and(account) + .and(certs)?; + + Ok(()) +} + +pub async fn delete(pool: &sqlx::AnyPool) -> anyhow::Result<()> { + match pool.any_kind() { + sqlx::any::AnyKind::MySql => { + let _ = delete_mysql(pool).await; + } + } + + let _ = tokio::fs::remove_file("signing_keys.json").await; + + let _ = tokio::fs::remove_dir_all("config").await; + + Ok(()) +} diff --git a/src/setup/mysql/account.sql b/src/setup/mysql/account.sql new file mode 100644 index 0000000..317af35 --- /dev/null +++ b/src/setup/mysql/account.sql @@ -0,0 +1,6 @@ +CREATE TABLE account ( + id BIGINT NOT NULL AUTO_INCREMENT, + -- UTC timestamp! (UTC_TIMESTAMP()) + create_time DATETIME NOT NULL, + PRIMARY KEY(id) +); diff --git a/src/setup/mysql/account_tokens.sql b/src/setup/mysql/account_tokens.sql new file mode 100644 index 0000000..9310caa --- /dev/null +++ b/src/setup/mysql/account_tokens.sql @@ -0,0 +1,14 @@ +CREATE TABLE account_tokens ( + account_id BIGINT NOT NULL, + token BINARY(16) NOT NULL, + valid_until DATETIME NOT NULL, + -- IMPORTANT: keep with in sync with the AccountTokenType enum in src/types.rs + ty ENUM( + 'logoutall', + 'linkcredential', + 'unlinkcredential', + 'delete' + ) NOT NULL, + FOREIGN KEY(account_id) REFERENCES account(id), + PRIMARY KEY(token) USING HASH +) ENGINE = MEMORY; diff --git a/src/setup/mysql/certs.sql b/src/setup/mysql/certs.sql new file mode 100644 index 0000000..cacb6a5 --- /dev/null +++ b/src/setup/mysql/certs.sql @@ -0,0 +1,7 @@ +CREATE TABLE certs ( + id BIGINT NOT NULL AUTO_INCREMENT, + cert_der BLOB, + -- UTC timestamp! (UTC_TIMESTAMP()) + valid_until DATETIME NOT NULL, + PRIMARY KEY(id) +); diff --git a/src/setup/mysql/credential_auth_tokens.sql b/src/setup/mysql/credential_auth_tokens.sql new file mode 100644 index 0000000..208802c --- /dev/null +++ b/src/setup/mysql/credential_auth_tokens.sql @@ -0,0 +1,13 @@ +CREATE TABLE credential_auth_tokens ( + token BINARY(16) NOT NULL, + valid_until DATETIME NOT NULL, + -- IMPORTANT: keep with in sync with the TokenType enum in src/types.rs + ty ENUM('email', 'steam') NOT NULL, + -- IMPORTANT: keep with in sync with the AccountTokenType enum in src/types.rs + op ENUM('login', 'linkcredential', 'unlinkcredential') NOT NULL, + -- the email or steamid or similar depending on above type. + identifier VARCHAR(255) NOT NULL, + PRIMARY KEY(token) USING HASH, + INDEX ty_identifier (ty, identifier) USING HASH, + INDEX(identifier) USING HASH +) ENGINE = MEMORY; diff --git a/src/setup/mysql/credential_email.sql b/src/setup/mysql/credential_email.sql new file mode 100644 index 0000000..f2c5a92 --- /dev/null +++ b/src/setup/mysql/credential_email.sql @@ -0,0 +1,7 @@ +CREATE TABLE credential_email ( + account_id BIGINT NOT NULL, + email VARCHAR(255) NOT NULL, + valid_until DATETIME NOT NULL, + FOREIGN KEY(account_id) REFERENCES account(id), + PRIMARY KEY(email) +); diff --git a/src/setup/mysql/credential_steam.sql b/src/setup/mysql/credential_steam.sql new file mode 100644 index 0000000..9e29bbc --- /dev/null +++ b/src/setup/mysql/credential_steam.sql @@ -0,0 +1,7 @@ +CREATE TABLE credential_steam ( + account_id BIGINT NOT NULL, + steamid64 BIGINT NOT NULL, + valid_until DATETIME NOT NULL, + FOREIGN KEY(account_id) REFERENCES account(id), + PRIMARY KEY(steamid64) +); diff --git a/src/setup/mysql/delete/account.sql b/src/setup/mysql/delete/account.sql new file mode 100644 index 0000000..5dfb76b --- /dev/null +++ b/src/setup/mysql/delete/account.sql @@ -0,0 +1 @@ +DROP TABLE account; diff --git a/src/setup/mysql/delete/account_tokens.sql b/src/setup/mysql/delete/account_tokens.sql new file mode 100644 index 0000000..d84bd4b --- /dev/null +++ b/src/setup/mysql/delete/account_tokens.sql @@ -0,0 +1 @@ +DROP TABLE account_tokens; diff --git a/src/setup/mysql/delete/certs.sql b/src/setup/mysql/delete/certs.sql new file mode 100644 index 0000000..5374404 --- /dev/null +++ b/src/setup/mysql/delete/certs.sql @@ -0,0 +1 @@ +DROP TABLE certs; diff --git a/src/setup/mysql/delete/credential_auth_tokens.sql b/src/setup/mysql/delete/credential_auth_tokens.sql new file mode 100644 index 0000000..d6d9ce3 --- /dev/null +++ b/src/setup/mysql/delete/credential_auth_tokens.sql @@ -0,0 +1 @@ +DROP TABLE credential_auth_tokens; diff --git a/src/setup/mysql/delete/credential_email.sql b/src/setup/mysql/delete/credential_email.sql new file mode 100644 index 0000000..b8214ba --- /dev/null +++ b/src/setup/mysql/delete/credential_email.sql @@ -0,0 +1 @@ +DROP TABLE credential_email; diff --git a/src/setup/mysql/delete/credential_steam.sql b/src/setup/mysql/delete/credential_steam.sql new file mode 100644 index 0000000..498d05d --- /dev/null +++ b/src/setup/mysql/delete/credential_steam.sql @@ -0,0 +1 @@ +DROP TABLE credential_steam; diff --git a/src/setup/mysql/delete/session.sql b/src/setup/mysql/delete/session.sql new file mode 100644 index 0000000..66ccd87 --- /dev/null +++ b/src/setup/mysql/delete/session.sql @@ -0,0 +1 @@ +DROP TABLE user_session; diff --git a/src/setup/mysql/session.sql b/src/setup/mysql/session.sql new file mode 100644 index 0000000..329b815 --- /dev/null +++ b/src/setup/mysql/session.sql @@ -0,0 +1,7 @@ +CREATE TABLE user_session ( + account_id BIGINT NOT NULL, + pub_key BINARY(32) NOT NULL, + hw_id BINARY(32) NOT NULL, + FOREIGN KEY(account_id) REFERENCES account(id), + UNIQUE KEY(pub_key) +); diff --git a/src/shared.rs b/src/shared.rs new file mode 100644 index 0000000..e60e6f6 --- /dev/null +++ b/src/shared.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use chrono::TimeDelta; +use parking_lot::RwLock; + +use crate::{ + certs::PrivateKeys, db::DbConnectionShared, email::EmailShared, ip_limit::IpDenyList, + steam::SteamShared, +}; + +pub const CERT_MAX_AGE_DELTA: TimeDelta = TimeDelta::seconds(20 * 60); +pub const CERT_MIN_AGE_DELTA: TimeDelta = TimeDelta::seconds(-20 * 60); + +/// Shared data across the implementation +pub struct Shared { + pub db: DbConnectionShared, + pub email: EmailShared, + pub steam: SteamShared, + /// A list of banned ips, e.g. to block VPNs + pub ip_ban_list: Arc>, + /// A signing key to sign the certificates for the account users. + pub signing_keys: Arc>>, + /// All certificates that are valid for any certificate generated + /// by any legit account server. + pub cert_chain: Arc>>>, + /// The email template for credential auth tokens + pub credential_auth_tokens_email: Arc>>, + /// The email template for account tokens + pub account_tokens_email: Arc>>, +} diff --git a/src/sign.rs b/src/sign.rs new file mode 100644 index 0000000..bea4b9b --- /dev/null +++ b/src/sign.rs @@ -0,0 +1,100 @@ +pub mod queries; + +use std::{str::FromStr, sync::Arc, time::Duration}; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + cert_account_ext::{AccountCertData, AccountCertExt}, + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, + sign::SignResponseSuccess, + }, + client::sign::SignRequest, +}; +use axum::Json; +use p256::ecdsa::DerSignature; +use sqlx::{Acquire, AnyPool}; +use x509_cert::builder::Builder; +use x509_cert::der::Encode; +use x509_cert::{ + builder::Profile, name::Name, serial_number::SerialNumber, spki::SubjectPublicKeyInfoOwned, + time::Validity, +}; + +use crate::shared::{Shared, CERT_MAX_AGE_DELTA, CERT_MIN_AGE_DELTA}; + +use self::queries::AuthAttempt; + +pub async fn sign_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json( + sign(shared, pool, data) + .await + .map_err(|err| AccountServerRequestError::Unexpected { + target: "sign".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + }), + ) +} + +pub async fn sign( + shared: Arc, + pool: AnyPool, + data: SignRequest, +) -> anyhow::Result { + data.account_data + .public_key + .verify_strict(data.time_stamp.to_string().as_bytes(), &data.signature)?; + let now = chrono::Utc::now(); + let delta = now.signed_duration_since(data.time_stamp); + anyhow::ensure!( + delta < CERT_MAX_AGE_DELTA && delta > CERT_MIN_AGE_DELTA, + "time stamp was not in a valid time frame." + ); + + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + let qry = AuthAttempt { data: &data }; + let row = qry + .query(&shared.db.auth_attempt_statement) + .fetch_one(connection) + .await?; + let auth_data = AuthAttempt::row_data(&row)?; + + let serial_number = SerialNumber::from(42u32); + let validity = Validity::from_now(Duration::new(60 * 60, 0))?; + let profile = Profile::Root; + let subject = Name::from_str("O=DDNet")?; + + let pub_key = SubjectPublicKeyInfoOwned::from_key(data.account_data.public_key)?; + + let signing_key = shared.signing_keys.read().clone(); + + let mut builder = x509_cert::builder::CertificateBuilder::new( + profile, + serial_number, + validity, + subject, + pub_key, + &signing_key.current_key, + )?; + let unix_utc = auth_data + .creation_date + .signed_duration_since(sqlx::types::chrono::DateTime::UNIX_EPOCH); + + builder.add_extension(&AccountCertExt { + data: AccountCertData { + account_id: auth_data.account_id, + utc_time_since_unix_epoch_millis: unix_utc.num_milliseconds(), + }, + })?; + let cert = builder.build::()?.to_der()?; + + Ok(SignResponseSuccess { cert_der: cert }) +} diff --git a/src/sign/mysql/auth.sql b/src/sign/mysql/auth.sql new file mode 100644 index 0000000..251b997 --- /dev/null +++ b/src/sign/mysql/auth.sql @@ -0,0 +1,10 @@ +SELECT + user_session.account_id, + account.create_time +FROM + account, + user_session +WHERE + user_session.pub_key = ? + AND user_session.hw_id = ? + AND account.id = user_session.account_id; diff --git a/src/sign/queries.rs b/src/sign/queries.rs new file mode 100644 index 0000000..ef98668 --- /dev/null +++ b/src/sign/queries.rs @@ -0,0 +1,44 @@ +use account_sql::query::Query; +use accounts_shared::client::sign::SignRequest; +use accounts_types::account_id::AccountId; +use sqlx::any::AnyRow; +use sqlx::types::chrono::DateTime; +use sqlx::types::chrono::Utc; +use sqlx::Executor; +use sqlx::Row; +use sqlx::Statement; + +#[derive(Debug)] +pub struct AuthAttempt<'a> { + pub data: &'a SignRequest, +} + +#[derive(Debug)] +pub struct AuthAttemptData { + pub account_id: AccountId, + pub creation_date: DateTime, +} + +#[async_trait::async_trait] +impl<'a> Query for AuthAttempt<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection.prepare(include_str!("mysql/auth.sql")).await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement + .query() + .bind(self.data.account_data.public_key.as_bytes().as_slice()) + .bind(self.data.account_data.hw_id.as_slice()) + } + fn row_data(row: &AnyRow) -> anyhow::Result { + Ok(AuthAttemptData { + account_id: row.try_get("account_id")?, + creation_date: row.try_get("create_time")?, + }) + } +} diff --git a/src/steam.rs b/src/steam.rs new file mode 100644 index 0000000..300d781 --- /dev/null +++ b/src/steam.rs @@ -0,0 +1,164 @@ +use std::{fmt::Debug, sync::Arc}; + +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SteamUser { + pub result: String, + #[serde(rename = "steamid")] + pub steam_id: String, + #[serde(rename = "ownersteamid")] + pub owner_steam_id: String, + #[serde(rename = "vacbanned")] + pub vac_banned: bool, + #[serde(rename = "publisherbanned")] + pub publisher_banned: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TicketAuthResponse { + pub params: SteamUser, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HttpResult { + pub response: TicketAuthResponse, +} + +pub trait SteamHook: Debug + Sync + Send { + fn on_steam_code(&self, steam_code: &[u8]); +} + +#[derive(Debug)] +struct SteamHookDummy {} +impl SteamHook for SteamHookDummy { + fn on_steam_code(&self, _steam_code: &[u8]) { + // empty + } +} + +/// Shared steam helper +#[derive(Debug)] +pub struct SteamShared { + http: reqwest::Client, + steam_hook: Arc, + + steam_auth_url: String, + publisher_auth_key: String, + identity: Option, + app_id: u32, +} + +/// https://partner.steamgames.com/doc/webapi/ISteamUserAuth#AuthenticateUserTicket +pub const OFFICIAL_STEAM_AUTH_URL: &str = + "https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1/"; + +impl SteamShared { + pub fn new( + steam_auth_url: Url, + publisher_auth_key: &str, + identity: Option<&str>, + app_id: u32, + ) -> anyhow::Result { + let http = reqwest::Client::new(); + + Ok(Self { + http, + steam_hook: Arc::new(SteamHookDummy {}), + + app_id, + publisher_auth_key: publisher_auth_key.to_string(), + identity: identity.map(|i| i.to_string()), + steam_auth_url: steam_auth_url.to_string(), + }) + } + + /// A hook that can see all sent steam token requests. + /// Currently only useful for testing + #[allow(dead_code)] + pub fn set_hook(&mut self, hook: F) { + self.steam_hook = Arc::new(hook); + } + + pub async fn verify_steamid64(&self, steam_ticket: Vec) -> anyhow::Result { + self.steam_hook.on_steam_code(&steam_ticket); + + let ticket = hex::encode(steam_ticket); + + let url = self.identity.as_ref().map_or_else( + || { + format!( + "{}?key={}&appid={}&ticket={}", + self.steam_auth_url, self.publisher_auth_key, self.app_id, ticket + ) + }, + |identity| { + format!( + "{}?key={}&appid={}&ticket={}&identity={}", + self.steam_auth_url, self.publisher_auth_key, self.app_id, ticket, identity + ) + }, + ); + + let steam_ticket_res: String = self.http.get(url).send().await?.text().await?; + + let ticket_res: HttpResult = serde_json::from_str(&steam_ticket_res)?; + Ok(ticket_res.response.params.steam_id.parse()?) + } +} + +#[cfg(test)] +mod test { + use axum::{extract::Query, response::IntoResponse, routing::get, Json, Router}; + use serde::Deserialize; + + use crate::steam::{HttpResult, SteamShared}; + + #[tokio::test] + async fn steam_test() { + // from https://partner.steamgames.com/doc/webapi/ISteamUserAuth#AuthenticateUserTicket + #[derive(Debug, Deserialize)] + struct SteamQueryParams { + pub key: String, + pub appid: u32, + pub ticket: String, + pub identity: Option, + } + async fn steam_id_check( + Query(q): Query, + ) -> axum::response::Response { + dbg!(q.key, q.appid, q.ticket, q.identity); + + Json(HttpResult { + response: crate::steam::TicketAuthResponse { + params: crate::steam::SteamUser { + result: "Ok".to_string(), + steam_id: "0".to_string(), + owner_steam_id: "0".to_string(), + vac_banned: false, + publisher_banned: false, + }, + }, + }) + .into_response() + } + let app = Router::new().route("/", get(steam_id_check)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:4433") + .await + .unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await }); + + let steam = SteamShared::new( + "http://127.0.0.1:4433/".try_into().unwrap(), + "the_secret_publisher_key", + Some("account"), + 1337, + ) + .unwrap(); + + let steamid = steam.verify_steamid64(vec![]).await.unwrap(); + assert!(steamid == 0); + } +} diff --git a/src/tests/credential_auth_token.rs b/src/tests/credential_auth_token.rs new file mode 100644 index 0000000..c7a78d6 --- /dev/null +++ b/src/tests/credential_auth_token.rs @@ -0,0 +1,111 @@ +use std::{str::FromStr, sync::Arc}; + +use account_client::credential_auth_token::CredentialAuthTokenResult; +use accounts_shared::{ + account_server::errors::AccountServerRequestError, + client::credential_auth_token::CredentialAuthTokenOperation, +}; +use client_reqwest::client::ClientReqwestTokioFs; +use email_address::EmailAddress; +use parking_lot::Mutex; +use url::Host; + +use crate::{ + email_limit::{EmailDomainAllowList, EmailDomainDenyList}, + tests::types::TestAccServer, +}; + +/// Tests for ban list +#[tokio::test] +async fn credential_auth_token_ban_lists() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + // account server setup + let token: Arc> = Default::default(); + let reset_code: Arc> = Default::default(); + let acc_server = TestAccServer::new(token.clone(), reset_code.clone(), false, true).await?; + + let email = EmailAddress::from_str("test@localhost")?; + *acc_server.shared.email.deny_list.write() = EmailDomainDenyList { + domains: vec![Host::parse("localhost")?].into_iter().collect(), + }; + + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + + // localhost is banned + let res = account_client::credential_auth_token::credential_auth_token_email( + email.clone(), + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + + assert!(matches!( + res.unwrap_err(), + CredentialAuthTokenResult::AccountServerRequstError(AccountServerRequestError::Other( + _ + )) + )); + + *acc_server.shared.email.allow_list.write() = EmailDomainAllowList { + domains: vec![Host::parse("localhost")?].into_iter().collect(), + }; + + // localhost is allowed, but also still banned. Banning has higher precedence + let res = account_client::credential_auth_token::credential_auth_token_email( + email.clone(), + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + + assert!(matches!( + res.unwrap_err(), + CredentialAuthTokenResult::AccountServerRequstError(AccountServerRequestError::Other( + _ + )) + )); + + *acc_server.shared.email.deny_list.write() = EmailDomainDenyList::default(); + // localhost is allowed + let res = account_client::credential_auth_token::credential_auth_token_email( + email.clone(), + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + assert!(res.is_ok()); + + *acc_server.shared.email.allow_list.write() = EmailDomainAllowList { + domains: vec![Host::parse("ddnet.org")?].into_iter().collect(), + }; + + // only ddnet.org is allowed, localhost fails now, since an allow list is in use. + let res = account_client::credential_auth_token::credential_auth_token_email( + email, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + + assert!(matches!( + res.unwrap_err(), + CredentialAuthTokenResult::AccountServerRequstError(AccountServerRequestError::Other( + _ + )) + )); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} diff --git a/src/tests/full.rs b/src/tests/full.rs new file mode 100644 index 0000000..0d5dba3 --- /dev/null +++ b/src/tests/full.rs @@ -0,0 +1,348 @@ +use std::{ + str::FromStr, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use account_client::{ + certs::{certs_to_pub_keys, download_certs}, + logout::logout, + sign::SignResult, +}; +use accounts_shared::{ + account_server::{account_info::CredentialType, cert_account_ext::AccountCertExt}, + client::{ + account_token::AccountTokenOperation, credential_auth_token::CredentialAuthTokenOperation, + }, + game_server, +}; +use anyhow::anyhow; +use client_reqwest::client::ClientReqwestTokioFs; +use email_address::EmailAddress; +use parking_lot::Mutex; +use x509_cert::der::Decode; + +use crate::{ + certs::PrivateKeys, + generate_new_signing_keys, + tests::types::{TestAccServer, TestGameServer}, + update::update_impl, +}; + +#[tokio::test] +async fn account_full_process() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + + // account server setup + let token: Arc> = Default::default(); + let account_token: Arc> = Default::default(); + let acc_server = + TestAccServer::new(token.clone(), account_token.clone(), false, true).await?; + let pool = acc_server.pool.clone(); + let shared = acc_server.shared.clone(); + + let url = "http://localhost:4433"; + let client = + ClientReqwestTokioFs::new(vec![url.try_into()?], secure_dir_client.path()).await?; + + let login = || { + Box::pin(async { + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + + // do actual login for client + let token_hex = token.lock().clone(); + let account_data = account_client::login::login(token_hex, &*client).await?; + anyhow::Ok(account_data) + }) + }; + // the first login will also create the account + login().await?.1.write(&*client).await?; + + // create a current signed certificate on the account server + let cert = account_client::sign::sign(&*client).await?; + + let Ok(Some((_, account_data))) = x509_cert::Certificate::from_der(&cert.certificate_der)? + .tbs_certificate + .get::() + else { + return Err(anyhow!("no valid account data found.")); + }; + + assert!(account_data.data.account_id >= 1); + + // now comes game server + let game_server = TestGameServer::new(&pool).await?; + let game_server_data = game_server.game_server_data.clone(); + + // emulate a game server that downloads certs from account server to validate + // the account cert from the client. + let certs = download_certs(&*client).await?; + let keys = certs_to_pub_keys(&certs); + + // Now use the client cert to get the user id, which is either the account id + // or the public key fingerprint + let user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + assert!(user_id.account_id.is_some()); + + // What the game server usually does is to provide a mechanism for the client + // to auto login the user, this automatically registers new users (if it has a valid account id). + // And in case of an "upgrade" so that a user previously had no account id but + // uses the same public key again, it will move the points of this public key + // to that account. + let auto_login_res = + account_game_server::auto_login::auto_login(game_server_data.clone(), &pool, &user_id) + .await; + assert!(auto_login_res.is_ok_and(|v| v)); + // Logging in again simply will not create a new account, but otherwise works + let auto_login_res = + account_game_server::auto_login::auto_login(game_server_data.clone(), &pool, &user_id) + .await; + assert!(auto_login_res.is_ok_and(|v| !v)); + account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "nameless_tee", + ) + .await?; + + // remove this session + logout(&*client).await?; + + // signing should fail now + assert!(matches!( + account_client::sign::sign(&*client).await, + Err(SignResult::FsLikeError(_)) + )); + + // login again + login().await?.1.write(&*client).await?; + + let account_info = account_client::account_info::account_info(&*client).await?; + assert!(account_info + .credentials + .iter() + .any(|c| if let CredentialType::Email(mail) = c { + mail == "t**t@l*******t" + } else { + false + })); + + // since the next step is only a logout, the user id must stay + // the same + let cert = account_client::sign::sign(&*client).await?; + let before_logout_user_id = + game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + + // remove all sessions except the current one + account_client::account_token::account_token_email( + EmailAddress::from_str("test@localhost")?, + AccountTokenOperation::LogoutAll, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + account_client::logout_all::logout_all(account_token_hex, &*client).await?; + + // signing should still work + assert!(account_client::sign::sign(&*client).await.is_ok(),); + + // login again + login().await?.1.write(&*client).await?; + + let cert = account_client::sign::sign(&*client).await?; + let after_logout_user_id = + game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + // make sure the account itself is still valid + assert!(after_logout_user_id.account_id == before_logout_user_id.account_id); + + // delete account + account_client::account_token::account_token_email( + EmailAddress::from_str("test@localhost")?, + AccountTokenOperation::Delete, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + account_client::delete::delete(account_token_hex, &*client).await?; + + // signing should fail now + assert!(matches!( + account_client::sign::sign(&*client).await, + Err(SignResult::FsLikeError(_)) + )); + + // login again, should create a new account + login().await?.1.write(&*client).await?; + let cert = account_client::sign::sign(&*client).await?; + let after_delete_user_id = + game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + // make sure the account itself was deleted properly, account ids should differ + assert!(after_logout_user_id.account_id != after_delete_user_id.account_id); + + // use link credential as rename for the email + account_client::account_token::account_token_email( + EmailAddress::from_str("test@localhost")?, + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test2@localhost")?, + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let token_hex = token.lock().clone(); + account_client::link_credential::link_credential(account_token_hex, token_hex, &*client) + .await?; + + // login with test2 should work + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test2@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + + // do actual login for client + let token_hex = token.lock().clone(); + account_client::login::login(token_hex, &*client) + .await? + .1 + .write(&*client) + .await?; + + let cert = account_client::sign::sign(&*client).await?; + let after_link_credential_user_id = + game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + assert!(after_link_credential_user_id.account_id == after_delete_user_id.account_id); + + let credential_auth_token_hex = + account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + account_client::login::login(credential_auth_token_hex, &*client) + .await? + .1 + .write(&*client) + .await?; + + let link_email = || { + Box::pin(async { + let account_token_hex = account_client::account_token::account_token_steam( + b"justatest".to_vec(), + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await?; + anyhow::Ok(()) + }) + }; + link_email().await?; + + // unlink email + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::UnlinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + account_client::unlink_credential::unlink_credential(credential_auth_token_hex, &*client) + .await?; + + link_email().await?; + + // unlink steam + let credential_auth_token_hex = + account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::UnlinkCredential, + None, + &*client, + ) + .await?; + account_client::unlink_credential::unlink_credential(credential_auth_token_hex, &*client) + .await?; + + game_server.destroy().await?; + // game server end + + // test some account server related stuff + // updates (which usually do cleanup tasks) + update_impl(&pool, &shared).await; + + // generate new signing keys + let cur_keys = shared.signing_keys.read().clone(); + let mut fake_cert = cur_keys.current_cert.clone(); + fake_cert.tbs_certificate.validity.not_after = SystemTime::now().try_into().unwrap(); + let fake_keys = PrivateKeys { + current_key: cur_keys.current_key.clone(), + current_cert: fake_cert, + next_key: cur_keys.next_key.clone(), + next_cert: cur_keys.next_cert.clone(), + }; + *shared.signing_keys.write() = Arc::new(fake_keys); + generate_new_signing_keys(&pool, &shared).await; + + // if above worked both keys should be around same lifetime + let cur_keys = shared.signing_keys.read().clone(); + // assumes that this test never runs for a whole day... + anyhow::ensure!( + cur_keys + .current_cert + .tbs_certificate + .validity + .not_after + .to_system_time() + + Duration::from_secs(60 * 60 * 24) + > cur_keys + .next_cert + .tbs_certificate + .validity + .not_after + .to_system_time(), + "certs do not have a similar lifetime" + ); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + + test.await.unwrap() +} diff --git a/src/tests/game_server/mod.rs b/src/tests/game_server/mod.rs new file mode 100644 index 0000000..8221304 --- /dev/null +++ b/src/tests/game_server/mod.rs @@ -0,0 +1 @@ +pub mod rename; diff --git a/src/tests/game_server/rename.rs b/src/tests/game_server/rename.rs new file mode 100644 index 0000000..ee18bbf --- /dev/null +++ b/src/tests/game_server/rename.rs @@ -0,0 +1,198 @@ +use std::{str::FromStr, sync::Arc}; + +use account_client::certs::{certs_to_pub_keys, download_certs}; +use account_game_server::rename::RenameError; +use accounts_shared::{ + account_server::cert_account_ext::AccountCertExt, + client::credential_auth_token::CredentialAuthTokenOperation, game_server, +}; +use anyhow::anyhow; +use client_reqwest::client::ClientReqwestTokioFs; +use email_address::EmailAddress; +use parking_lot::Mutex; +use x509_cert::der::Decode; + +use crate::tests::types::{TestAccServer, TestGameServer}; + +#[tokio::test] +async fn rename_hardening() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + + // account server setup + let token: Arc> = Default::default(); + let account_token: Arc> = Default::default(); + let acc_server = + TestAccServer::new(token.clone(), account_token.clone(), false, true).await?; + let pool = acc_server.pool.clone(); + + let url = "http://localhost:4433"; + let client = + ClientReqwestTokioFs::new(vec![url.try_into()?], secure_dir_client.path()).await?; + + let login = |email: EmailAddress| { + Box::pin(async { + account_client::credential_auth_token::credential_auth_token_email( + email, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + + // do actual login for client + let token_hex = token.lock().clone(); + let account_data = account_client::login::login(token_hex, &*client).await?; + anyhow::Ok(account_data) + }) + }; + // the first login will also create the account + login(EmailAddress::from_str("test@localhost")?) + .await? + .1 + .write(&*client) + .await?; + + // create a current signed certificate on the account server + let cert = account_client::sign::sign(&*client).await?; + + let Ok(Some((_, account_data))) = x509_cert::Certificate::from_der(&cert.certificate_der)? + .tbs_certificate + .get::() + else { + return Err(anyhow!("no valid account data found.")); + }; + + assert!(account_data.data.account_id >= 1); + + // now comes game server + let game_server = TestGameServer::new(&pool).await?; + let game_server_data = game_server.game_server_data.clone(); + + let certs = download_certs(&*client).await?; + let keys = certs_to_pub_keys(&certs); + + let user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + assert!(user_id.account_id.is_some()); + + // Login the user + let auto_login_res = + account_game_server::auto_login::auto_login(game_server_data.clone(), &pool, &user_id) + .await; + assert!(auto_login_res.is_ok_and(|v| v)); + + account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "nameless_tee", + ) + .await?; + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "nameless+ee", + ) + .await; + assert!(matches!(res, Err(RenameError::InvalidAscii))); + let res = + account_game_server::rename::rename(game_server_data.clone(), &pool, &user_id, "name.") + .await; + assert!(matches!(res, Err(RenameError::InvalidAscii))); + let res = + account_game_server::rename::rename(game_server_data.clone(), &pool, &user_id, "name-") + .await; + assert!(matches!(res, Err(RenameError::InvalidAscii))); + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "name tee", + ) + .await; + assert!(matches!(res, Err(RenameError::InvalidAscii))); + let res = + account_game_server::rename::rename(game_server_data.clone(), &pool, &user_id, "name'") + .await; + assert!(matches!(res, Err(RenameError::InvalidAscii))); + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "name\"", + ) + .await; + assert!(matches!(res, Err(RenameError::InvalidAscii))); + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "autouser123", + ) + .await; + assert!(matches!(res, Err(RenameError::ReservedName))); + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "autouserre", + ) + .await; + assert!(matches!(res, Err(RenameError::ReservedName))); + let res = + account_game_server::rename::rename(game_server_data.clone(), &pool, &user_id, "a") + .await; + assert!(matches!(res, Err(RenameError::NameLengthInvalid))); + let res = + account_game_server::rename::rename(game_server_data.clone(), &pool, &user_id, "ab") + .await; + assert!(matches!(res, Err(RenameError::NameLengthInvalid))); + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &user_id, + "012345678901234567890123456789012", + ) + .await; + assert!(matches!(res, Err(RenameError::NameLengthInvalid))); + + // create another user + login(EmailAddress::from_str("test2@localhost")?) + .await? + .1 + .write(&*client) + .await?; + let cert = account_client::sign::sign(&*client).await?; + let mut new_user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &new_user_id, + "nameless_tee", + ) + .await; + assert!(matches!(res, Err(RenameError::NameAlreadyExists))); + + // Act as if the user has no account + new_user_id.account_id = None; + + let res = account_game_server::rename::rename( + game_server_data.clone(), + &pool, + &new_user_id, + "nameless_tee2", + ) + .await; + assert!(matches!(res, Ok(false))); + + game_server.destroy().await?; + // game server end + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + + test.await.unwrap() +} diff --git a/src/tests/ip_ban.rs b/src/tests/ip_ban.rs new file mode 100644 index 0000000..153dc55 --- /dev/null +++ b/src/tests/ip_ban.rs @@ -0,0 +1,74 @@ +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + str::FromStr, + sync::Arc, +}; + +use account_client::credential_auth_token::CredentialAuthTokenResult; +use accounts_shared::{ + account_server::errors::AccountServerRequestError, + client::credential_auth_token::CredentialAuthTokenOperation, +}; +use client_reqwest::client::ClientReqwestTokioFs; +use email_address::EmailAddress; +use iprange::IpRange; +use parking_lot::Mutex; + +use crate::{ip_limit::IpDenyList, tests::types::TestAccServer}; + +/// Tests for ip ban list +#[tokio::test] +async fn ip_ban_lists() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + // account server setup + let token: Arc> = Default::default(); + let reset_code: Arc> = Default::default(); + let acc_server = TestAccServer::new(token.clone(), reset_code.clone(), false, true).await?; + + let email = EmailAddress::from_str("test@localhost")?; + *acc_server.shared.ip_ban_list.write() = IpDenyList { + ipv4: { + let mut ip = IpRange::new(); + + ip.add(Ipv4Addr::from_str("127.0.0.1")?.into()); + + ip + }, + ipv6: { + let mut ip = IpRange::new(); + + ip.add(Ipv6Addr::from_str("::1")?.into()); + + ip + }, + }; + + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + + // 127.0.0.1 is banned + let res = account_client::credential_auth_token::credential_auth_token_email( + email, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + + assert!(matches!( + res.unwrap_err(), + CredentialAuthTokenResult::AccountServerRequstError(AccountServerRequestError::VpnBan( + _ + )) + )); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} diff --git a/src/tests/link_credential.rs b/src/tests/link_credential.rs new file mode 100644 index 0000000..a73cd96 --- /dev/null +++ b/src/tests/link_credential.rs @@ -0,0 +1,293 @@ +use std::{str::FromStr, sync::Arc}; + +use account_client::{ + account_token::AccountTokenResult, + certs::{certs_to_pub_keys, download_certs}, + link_credential::LinkCredentialResult, +}; +use accounts_shared::{ + client::{ + account_token::AccountTokenOperation, credential_auth_token::CredentialAuthTokenOperation, + }, + game_server, +}; +use client_reqwest::client::ClientReqwestTokioFs; +use email_address::EmailAddress; +use parking_lot::Mutex; + +use crate::tests::types::TestAccServer; + +/// Tests related to verifying that link credential does +/// what it should and fails appropriately +#[tokio::test] +async fn link_credential_hardening() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + // account server setup + let token: Arc> = Default::default(); + let account_token: Arc> = Default::default(); + let acc_server = + TestAccServer::new(token.clone(), account_token.clone(), false, true).await?; + + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + + let certs = download_certs(&*client).await?; + let keys = certs_to_pub_keys(&certs); + + // create an account + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + let token_hex = token.lock().clone(); + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + let cert = account_client::sign::sign(&*client).await?; + let user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + + // try to link the email against a non-existing steam account + // must fail + let account_token_hex = account_client::account_token::account_token_steam( + b"justatest".to_vec(), + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await; + assert!(matches!( + account_token_hex, + Err(AccountTokenResult::AccountServerRequstError(_)) + )); + + // don't allow emails with display name or ips + let res = account_client::account_token::account_token_email( + EmailAddress::from_str("Name ")?, + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await; + assert!(matches!( + res, + Err(AccountTokenResult::AccountServerRequstError(_)) + )); + let res = account_client::account_token::account_token_email( + EmailAddress::from_str("test@[127.0.0.1]")?, + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await; + assert!(matches!( + res, + Err(AccountTokenResult::AccountServerRequstError(_)) + )); + + // rename the linked email + account_client::account_token::account_token_email( + EmailAddress::from_str("test@localhost")?, + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test2@localhost")?, + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await?; + + // use a wrong account token operation + account_client::account_token::account_token_email( + EmailAddress::from_str("test2@localhost")?, + AccountTokenOperation::Delete, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + let res = account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await; + assert!(matches!( + res, + Err(LinkCredentialResult::AccountServerRequstError(_)) + )); + + // login with new email + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test2@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + let token_hex = token.lock().clone(); + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + // match old & new user_id + let cert = account_client::sign::sign(&*client).await?; + let new_user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + assert!(user_id.account_id == new_user_id.account_id); + + // link steam to the account + account_client::account_token::account_token_email( + EmailAddress::from_str("test2@localhost")?, + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + let credential_auth_token_hex = + account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await?; + + // login by steam + let token_hex = account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + // match old & new user_id + let cert = account_client::sign::sign(&*client).await?; + let new_user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + assert!(user_id.account_id == new_user_id.account_id); + + // create an account on the old email + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + let token_hex = token.lock().clone(); + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + // make sure the accounts differ + let cert = account_client::sign::sign(&*client).await?; + let new_user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + assert!(user_id.account_id != new_user_id.account_id); + + // try to link steam against the new email + account_client::account_token::account_token_email( + EmailAddress::from_str("test@localhost")?, + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + let credential_auth_token_hex = + account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let res = account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await; + assert!(matches!( + res, + Err(LinkCredentialResult::AccountServerRequstError(_)) + )); + + // try to link the original email against the steam account + // which should fail because the original email already has a different account + let account_token_hex = account_client::account_token::account_token_steam( + b"justatest".to_vec(), + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + let res = account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await; + assert!(matches!( + res, + Err(LinkCredentialResult::AccountServerRequstError(_)) + )); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} diff --git a/src/tests/login.rs b/src/tests/login.rs new file mode 100644 index 0000000..f566b40 --- /dev/null +++ b/src/tests/login.rs @@ -0,0 +1,194 @@ +use std::{str::FromStr, sync::Arc}; + +use account_client::{credential_auth_token::CredentialAuthTokenResult, login::LoginResult}; +use accounts_shared::{ + account_server::{errors::AccountServerRequestError, login::LoginError}, + client::credential_auth_token::CredentialAuthTokenOperation, +}; +use client_reqwest::client::ClientReqwestTokioFs; +use email_address::EmailAddress; +use parking_lot::Mutex; + +use crate::tests::types::TestAccServer; + +/// Tests related to [`CredentialAuthTokenResult`] & [`LoginResult`] & server side login +#[tokio::test] +async fn login_rate_limit() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + // account server setup + let token: Arc> = Default::default(); + let reset_code: Arc> = Default::default(); + let acc_server = TestAccServer::new(token.clone(), reset_code.clone(), true, true).await?; + + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + + // do actual login for client + let token_hex = token.lock().clone(); + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + let err = account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await + .unwrap_err(); + assert!(matches!( + err, + CredentialAuthTokenResult::AccountServerRequstError( + AccountServerRequestError::RateLimited(_) + ) + )); + + let _ = account_client::login::login(token_hex.clone(), &*client).await; + let _ = account_client::login::login(token_hex.clone(), &*client).await; + let _ = account_client::login::login(token_hex.clone(), &*client).await; + let _ = account_client::login::login(token_hex.clone(), &*client).await; + let _ = account_client::login::login(token_hex.clone(), &*client).await; + // After the 5th attempt it should rate limit + let err = account_client::login::login(token_hex.clone(), &*client) + .await + .unwrap_err(); + assert!(matches!( + err, + LoginResult::AccountServerRequstError(AccountServerRequestError::RateLimited(_)) + )); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} + +#[tokio::test] +async fn login_hardening() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + // account server setup + let token: Arc> = Default::default(); + let reset_code: Arc> = Default::default(); + let acc_server = TestAccServer::new(token.clone(), reset_code.clone(), false, true).await?; + + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + + // don't allow emails with display name or ips + let res = account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("Name ")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + assert!(matches!( + res, + Err(CredentialAuthTokenResult::AccountServerRequstError(_)) + )); + let res = account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@[127.0.0.1]")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + assert!(matches!( + res, + Err(CredentialAuthTokenResult::AccountServerRequstError(_)) + )); + + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + + let token_hex = token.lock().clone(); + // already use the token + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + let err = account_client::login::login("invalid".to_string(), &*client) + .await + .unwrap_err(); + assert!(matches!(err, LoginResult::Other(_))); + + // token can't be valid at this point anymore + let err = account_client::login::login(token_hex, &*client) + .await + .unwrap_err(); + assert!(matches!( + err, + LoginResult::AccountServerRequstError(AccountServerRequestError::LogicError( + LoginError::TokenInvalid + )) + )); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} + +#[tokio::test] +async fn login_email_test() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + // account server setup + let token: Arc> = Default::default(); + let reset_code: Arc> = Default::default(); + let acc_server = + TestAccServer::new(token.clone(), reset_code.clone(), false, false).await?; + + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + + // localhost is forbidden, since email_test_mode is false in TestAccServer::new + let res = account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await; + assert!(matches!( + res, + Err(CredentialAuthTokenResult::AccountServerRequstError(_)) + )); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..a5e234c --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,10 @@ +pub mod credential_auth_token; +pub mod full; +pub mod game_server; +pub mod ip_ban; +pub mod link_credential; +pub mod login; +pub mod multi_url; +pub mod signing_certs; +pub mod types; +pub mod unlink_credential; diff --git a/src/tests/multi_url.rs b/src/tests/multi_url.rs new file mode 100644 index 0000000..3af31de --- /dev/null +++ b/src/tests/multi_url.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use accounts_shared::client::credential_auth_token::CredentialAuthTokenOperation; +use client_reqwest::client::ClientReqwestTokioFs; +use parking_lot::Mutex; + +use crate::tests::types::TestAccServer; + +#[tokio::test] +async fn multi_url_test() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + + // account server setup + let token: Arc> = Default::default(); + let account_token: Arc> = Default::default(); + let acc_server = + TestAccServer::new(token.clone(), account_token.clone(), false, true).await?; + + // This request should fail. + let broken_url = "http://localhost:55443"; + let url = "http://localhost:4433"; + let client = ClientReqwestTokioFs::new( + vec![broken_url.try_into()?, url.try_into()?], + secure_dir_client.path(), + ) + .await?; + + assert!( + client + .client + .cur_http + .load(std::sync::atomic::Ordering::Relaxed) + == 0 + ); + let _ = account_client::certs::download_certs(&*client).await?; + assert!( + client + .client + .cur_http + .load(std::sync::atomic::Ordering::Relaxed) + == 1 + ); + + client + .client + .cur_http + .store(0, std::sync::atomic::Ordering::Relaxed); + let _ = account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + assert!( + client + .client + .cur_http + .load(std::sync::atomic::Ordering::Relaxed) + == 1 + ); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + + test.await.unwrap() +} diff --git a/src/tests/signing_certs.rs b/src/tests/signing_certs.rs new file mode 100644 index 0000000..4005cd3 --- /dev/null +++ b/src/tests/signing_certs.rs @@ -0,0 +1,120 @@ +use std::{sync::Arc, time::Duration}; + +use client_http_fs::cert_downloader::CertsDownloader; +use client_reqwest::client::ClientReqwestTokioFs; +use parking_lot::Mutex; + +use crate::{ + certs::{generate_key_and_cert_impl, get_certs, store_cert, PrivateKeys}, + generate_new_signing_keys_impl, + tests::types::TestAccServer, +}; + +/// Tests for creating signing certs, checking their validity. +/// Downloading their public keys. +#[tokio::test] +async fn signing_certs() { + let test = async move { + // account server setup + let token: Arc> = Default::default(); + let reset_code: Arc> = Default::default(); + let acc_server = TestAccServer::new(token.clone(), reset_code.clone(), false, true).await?; + + let (key, cert) = generate_key_and_cert_impl(Duration::from_secs(5))?; + let now = cert.tbs_certificate.validity.not_before.to_system_time(); + + store_cert(&acc_server.shared.db, &acc_server.pool, &cert).await?; + let certs = get_certs(&acc_server.shared.db, &acc_server.pool).await?; + // make sure our new cert is stored + assert!(certs.contains(&cert)); + + // also test the downloader task + let secure_dir_client = tempfile::tempdir()?; + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + let downloaded_certs = + account_client::certs::download_certs(client.client.as_ref()).await?; + assert!(!downloaded_certs.contains(&cert)); + + let cert_downloader = CertsDownloader::new(client.client.clone()).await?; + let invalid_in = cert_downloader.invalid_in(now, Duration::from_secs(0)); + // default certs are valid for at least 1 day + assert!(invalid_in.is_some_and(|i| i > Duration::from_secs(60 * 60 * 24))); + + *acc_server.shared.cert_chain.write() = Arc::new(certs); + let downloaded_certs = + account_client::certs::download_certs(client.client.as_ref()).await?; + assert!(downloaded_certs.contains(&cert)); + + // now force download the newest certs + cert_downloader.download_certs().await; + + // now the cert should be invalid in the previously specified time (now) + let invalid_in = cert_downloader.invalid_in(now, Duration::from_secs(0)); + // cert must be invalid in exactly 5 seconds from `now` + assert!(invalid_in.is_some_and(|i| i == Duration::from_secs(5))); + + // if an offset of 5 seconds is used, the cert should now be invalid + let invalid_in = cert_downloader.invalid_in(now, Duration::from_secs(5)); + assert!(invalid_in.is_some_and(|i| i == Duration::from_secs(0))); + + // if the cert was already invalid, then it should still return 0 + let invalid_in = cert_downloader.invalid_in(now, Duration::from_secs(15)); + assert!(invalid_in.is_some_and(|i| i == Duration::from_secs(0))); + + *acc_server.shared.signing_keys.write() = Arc::new(PrivateKeys { + current_key: key.clone(), + current_cert: cert.clone(), + next_key: key, + next_cert: cert, + }); + + let default_check_key_time = Duration::from_secs(5); + let err_check_key_time = Duration::from_secs(5); + let validy_extra_offset = Duration::from_secs(1); + + // Make sure the recently generated cert is valid + let res = generate_new_signing_keys_impl( + &acc_server.pool, + &acc_server.shared, + now, + default_check_key_time, + err_check_key_time, + validy_extra_offset, + ) + .await; + assert!(matches!(res, either::Either::Left(_))); + + // Make sure the offset is correct + let res = generate_new_signing_keys_impl( + &acc_server.pool, + &acc_server.shared, + now + default_check_key_time - validy_extra_offset - Duration::from_nanos(1), + default_check_key_time, + err_check_key_time, + validy_extra_offset, + ) + .await; + assert!(matches!(res, either::Either::Left(_))); + + // Make sure the offset would trigger generating a new key + let res = generate_new_signing_keys_impl( + &acc_server.pool, + &acc_server.shared, + now + default_check_key_time - validy_extra_offset, + default_check_key_time, + err_check_key_time, + validy_extra_offset, + ) + .await; + assert!(matches!(res, either::Either::Right(_))); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} diff --git a/src/tests/types.rs b/src/tests/types.rs new file mode 100644 index 0000000..8dce696 --- /dev/null +++ b/src/tests/types.rs @@ -0,0 +1,246 @@ +use std::{num::NonZeroU32, sync::Arc, time::Duration}; + +use axum::{extract::Query, response::IntoResponse, routing::get, Router}; +use lettre::SmtpTransport; +use parking_lot::Mutex; +use serde::Deserialize; +use sqlx::{Any, Pool}; +use tokio::{net::TcpSocket, task::JoinHandle}; + +use crate::{ + email::{EmailHook, EmailShared}, + prepare_db, prepare_http, prepare_statements, run, setup, + shared::Shared, + steam::{self, SteamHook, SteamShared}, +}; + +pub async fn test_setup() -> anyhow::Result> { + prepare_db(&crate::DbDetails { + host: "localhost".into(), + port: 3306, + database: "ddnet_account_test".into(), + username: "ddnet-account-test".into(), + password: "test".into(), + ca_cert_path: "/etc/mysql/ssl/ca-cert.pem".into(), + }) + .await +} + +pub struct TestAccServer { + pub(crate) server: JoinHandle>, + pub(crate) pool: Pool, + pub(crate) shared: Arc, + pub(crate) steam: JoinHandle>, +} + +impl TestAccServer { + pub(crate) async fn new( + token: Arc>, + account_token: Arc>, + limit: bool, + email_test_mode: bool, + ) -> anyhow::Result { + let pool = test_setup().await?; + + if let Err(err) = setup::delete(&pool).await { + println!("warning: {}", err); + } + setup::setup(&pool).await?; + + let db = prepare_statements(&pool).await?; + let mut email: EmailShared = + ("test@localhost", SmtpTransport::unencrypted_localhost()).into(); + email.set_test_mode(email_test_mode); + #[derive(Debug)] + struct EmailReader { + token: Arc>, + account_token: Arc>, + } + impl EmailHook for EmailReader { + fn on_mail(&self, email_subject: &str, email_body: &str) { + if [ + "DDNet Logout All Sessions", + "DDNet Link Credential", + "DDNet Delete Account", + ] + .contains(&email_subject) + { + let reg = regex::Regex::new(r".*
(.*)
.*").unwrap(); + let (_, [account_token]): (&str, [&str; 1]) = + reg.captures_iter(email_body).next().unwrap().extract(); + dbg!(account_token); + *self.account_token.lock() = account_token.to_string(); + } else { + let reg = regex::Regex::new(r".*
(.*)
.*").unwrap(); + let (_, [token]): (&str, [&str; 1]) = + reg.captures_iter(email_body).next().unwrap().extract(); + dbg!(token); + *self.token.lock() = token.to_string(); + } + } + } + email.set_hook(EmailReader { + token: token.clone(), + account_token: account_token.clone(), + }); + + let mut steam = SteamShared::new( + "http://127.0.0.1:3344".try_into()?, + "my_secret_pub_auth_key", + Some("account"), + 123, + )?; + #[derive(Debug)] + struct SteamReader {} + impl SteamHook for SteamReader { + fn on_steam_code(&self, steam_code: &[u8]) { + dbg!(steam_code); + } + } + steam.set_hook(SteamReader {}); + + // create a fake steam server + let tcp_socket = TcpSocket::new_v4()?; + tcp_socket.set_reuseaddr(true)?; + tcp_socket.bind(format!("127.0.0.1:{}", 3344).parse()?)?; + + // from https://partner.steamgames.com/doc/webapi/ISteamUserAuth#AuthenticateUserTicket + #[derive(Debug, Deserialize)] + struct SteamQueryParams { + pub key: String, + pub appid: u32, + pub ticket: String, + pub identity: Option, + } + async fn steam_id_check( + Query(q): Query, + ) -> axum::response::Response { + assert!(q.ticket == hex::encode("justatest")); + dbg!(q.key, q.appid, q.ticket, q.identity); + axum::Json(steam::HttpResult { + response: steam::TicketAuthResponse { + params: steam::SteamUser { + result: "".to_string(), + steam_id: 0.to_string(), + owner_steam_id: 0.to_string(), + vac_banned: false, + publisher_banned: false, + }, + }, + }) + .into_response() + } + let app = Router::new().route("/", get(steam_id_check)); + + let listener = tcp_socket.listen(1024)?; + let steam_handle = + tokio::spawn(async move { anyhow::Ok(axum::serve(listener, app).await?) }); + + let limit = if limit { + crate::LimiterSettings::default() + } else { + crate::LimiterSettings { + credential_auth_tokens: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + credential_auth_tokens_secret: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + account_tokens: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + account_tokens_secret: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + login: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + link_credential: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + unlink_credential: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + delete: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + logout_all: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + logout: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + account_info: crate::LimiterValues { + time_until_another_attempt: Duration::from_nanos(1), + initial_request_count: NonZeroU32::new(u32::MAX).unwrap(), + }, + } + }; + let (listener, app, shared) = prepare_http( + &crate::HttpServerDetails { port: 4433 }, + db, + email, + steam, + &pool, + &limit, + ) + .await?; + + let pool_clone = pool.clone(); + let shared_clone = shared.clone(); + let server = + tokio::spawn(async move { run(listener, app, pool_clone, shared_clone, false).await }); + + Ok(Self { + server, + pool, + shared, + steam: steam_handle, + }) + } + + pub(crate) async fn destroy(self) -> anyhow::Result<()> { + self.server.abort(); + self.steam.abort(); + + let _ = self.server.await; + + setup::delete(&self.pool).await?; + anyhow::Ok(()) + } +} + +pub struct TestGameServer { + pool: Pool, + pub(crate) game_server_data: Arc, +} + +impl TestGameServer { + pub(crate) async fn new(pool: &Pool) -> anyhow::Result { + // make sure the tables are gone + let _ = account_game_server::setup::delete(pool).await; + account_game_server::setup::setup(pool).await?; + + let game_server_data = account_game_server::prepare::prepare(pool).await?; + + Ok(Self { + pool: pool.clone(), + game_server_data, + }) + } + + pub(crate) async fn destroy(self) -> anyhow::Result<()> { + account_game_server::setup::delete(&self.pool).await?; + Ok(()) + } +} diff --git a/src/tests/unlink_credential.rs b/src/tests/unlink_credential.rs new file mode 100644 index 0000000..6dbb3c5 --- /dev/null +++ b/src/tests/unlink_credential.rs @@ -0,0 +1,200 @@ +use std::{str::FromStr, sync::Arc}; + +use account_client::{ + certs::{certs_to_pub_keys, download_certs}, + unlink_credential::UnlinkCredentialResult, +}; +use accounts_shared::{ + client::{ + account_token::AccountTokenOperation, credential_auth_token::CredentialAuthTokenOperation, + }, + game_server, +}; +use client_reqwest::client::ClientReqwestTokioFs; +use email_address::EmailAddress; +use parking_lot::Mutex; + +use crate::tests::types::TestAccServer; + +/// Tests related to verifying that unlinking credential does +/// what it should and fails appropriately +#[tokio::test] +async fn unlink_credential_hardening() { + let test = async move { + let secure_dir_client = tempfile::tempdir()?; + // account server setup + let token: Arc> = Default::default(); + let account_token: Arc> = Default::default(); + let acc_server = + TestAccServer::new(token.clone(), account_token.clone(), false, true).await?; + + let client = ClientReqwestTokioFs::new( + vec!["http://localhost:4433".try_into()?], + secure_dir_client.path(), + ) + .await?; + + let certs = download_certs(&*client).await?; + let keys = certs_to_pub_keys(&certs); + + // create an account + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + let token_hex = token.lock().clone(); + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + let cert = account_client::sign::sign(&*client).await?; + let user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + + // try to unlink the account even tho it has only 1 linked credential + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::UnlinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + let res = account_client::unlink_credential::unlink_credential( + credential_auth_token_hex, + &*client, + ) + .await; + assert!(matches!( + res, + Err(UnlinkCredentialResult::AccountServerRequstError(_)) + )); + + // link steam to the account + account_client::account_token::account_token_email( + EmailAddress::from_str("test@localhost")?, + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let account_token_hex = account_token.lock().clone(); + let credential_auth_token_hex = + account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await?; + + // try to unlink the email again + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::UnlinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + let res = account_client::unlink_credential::unlink_credential( + credential_auth_token_hex, + &*client, + ) + .await; + assert!(res.is_ok()); + + // this should create a new account + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test@localhost")?, + CredentialAuthTokenOperation::Login, + None, + &*client, + ) + .await?; + let token_hex = token.lock().clone(); + account_client::login::login(token_hex.clone(), &*client) + .await? + .1 + .write(&*client) + .await?; + + let cert = account_client::sign::sign(&*client).await?; + let new_user_id = game_server::user_id::user_id_from_cert(&keys, cert.certificate_der); + + assert!(user_id.account_id != new_user_id.account_id); + + // try to unlink steam from the account, which fails, bcs steam is the only credential + let credential_auth_token_hex = + account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::UnlinkCredential, + None, + &*client, + ) + .await?; + let res = account_client::unlink_credential::unlink_credential( + credential_auth_token_hex, + &*client, + ) + .await; + assert!(matches!( + res, + Err(UnlinkCredentialResult::AccountServerRequstError(_)) + )); + + // link an email to the account again + let account_token_hex = account_client::account_token::account_token_steam( + b"justatest".to_vec(), + AccountTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + account_client::credential_auth_token::credential_auth_token_email( + EmailAddress::from_str("test2@localhost")?, + CredentialAuthTokenOperation::LinkCredential, + None, + &*client, + ) + .await?; + let credential_auth_token_hex = token.lock().clone(); + account_client::link_credential::link_credential( + account_token_hex, + credential_auth_token_hex, + &*client, + ) + .await?; + + // Now unlinking steam from the account should work + let credential_auth_token_hex = + account_client::credential_auth_token::credential_auth_token_steam( + b"justatest".to_vec(), + CredentialAuthTokenOperation::UnlinkCredential, + None, + &*client, + ) + .await?; + let res = account_client::unlink_credential::unlink_credential( + credential_auth_token_hex, + &*client, + ) + .await; + assert!(res.is_ok()); + + acc_server.destroy().await?; + + anyhow::Ok(()) + }; + test.await.unwrap(); +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..ee03847 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,22 @@ +use accounts_shared::client::{ + account_token::AccountTokenOperation, credential_auth_token::CredentialAuthTokenOperation, +}; +use serde::{Deserialize, Serialize}; +use strum::{EnumString, IntoStaticStr}; + +// IMPORTANT: keep this in sync with the ty enum in src/setup/mysql/credential_auth_tokens.sql +/// The type of token that was created. +#[derive(Debug, Serialize, Deserialize, IntoStaticStr, EnumString, Clone, Copy)] +#[strum(serialize_all = "lowercase")] +pub enum TokenType { + Email, + Steam, +} + +// IMPORTANT: keep this in sync with the ty enum in src/setup/mysql/account_tokens.sql +/// The type of token that was created. +pub type AccountTokenType = AccountTokenOperation; + +// IMPORTANT: keep this in sync with the ty enum in src/setup/mysql/credential_auth_tokens.sql +/// The type of token that was created. +pub type CredentialAuthTokenType = CredentialAuthTokenOperation; diff --git a/src/unlink_credential.rs b/src/unlink_credential.rs new file mode 100644 index 0000000..eee125f --- /dev/null +++ b/src/unlink_credential.rs @@ -0,0 +1,98 @@ +pub mod queries; + +use std::{str::FromStr, sync::Arc}; + +use account_sql::query::Query; +use accounts_shared::{ + account_server::{ + errors::{AccountServerRequestError, Empty}, + result::AccountServerReqResult, + }, + client::unlink_credential::UnlinkCredentialRequest, +}; +use axum::Json; +use queries::{UnlinkCredentialByEmail, UnlinkCredentialBySteam}; +use sqlx::{Acquire, AnyPool, Connection}; + +use crate::{ + login::get_and_invalidate_credential_auth_token, + shared::Shared, + types::{CredentialAuthTokenType, TokenType}, +}; + +pub async fn unlink_credential_request( + shared: Arc, + pool: AnyPool, + Json(data): Json, +) -> Json> { + Json(unlink_credential(shared, pool, data).await.map_err(|err| { + AccountServerRequestError::Unexpected { + target: "unlink_credential".into(), + err: err.to_string(), + bt: err.backtrace().to_string(), + } + })) +} + +pub async fn unlink_credential( + shared: Arc, + pool: AnyPool, + data: UnlinkCredentialRequest, +) -> anyhow::Result<()> { + let mut connection = pool.acquire().await?; + let connection = connection.acquire().await?; + + connection + .transaction(|connection| { + Box::pin(async move { + let token_data = get_and_invalidate_credential_auth_token( + &shared, + data.credential_auth_token, + connection, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("Credential auth token is invalid/expired."))?; + anyhow::ensure!( + token_data.op == CredentialAuthTokenType::UnlinkCredential, + "Credential auth token was not for unlinking \ + the current credential from its account" + ); + + let affected_rows = match token_data.ty { + TokenType::Email => { + let email = email_address::EmailAddress::from_str(&token_data.identifier)?; + // remove the current email, if exists. + let qry = UnlinkCredentialByEmail { email: &email }; + + qry.query(&shared.db.unlink_credential_by_email_statement) + .execute(&mut **connection) + .await? + .rows_affected() + } + TokenType::Steam => { + let steamid64: i64 = token_data.identifier.parse()?; + // remove the current steam, if exists. + let qry = UnlinkCredentialBySteam { + steamid64: &steamid64, + }; + + qry.query(&shared.db.unlink_credential_by_steam_statement) + .execute(&mut **connection) + .await? + .rows_affected() + } + }; + + anyhow::ensure!( + affected_rows > 0, + "No credential was unlinked. \ + There has to be at least one credential per account." + ); + + anyhow::Ok(()) + }) + }) + .await?; + + Ok(()) +} diff --git a/src/unlink_credential/mysql/unlink_credential_email.sql b/src/unlink_credential/mysql/unlink_credential_email.sql new file mode 100644 index 0000000..1e3430e --- /dev/null +++ b/src/unlink_credential/mysql/unlink_credential_email.sql @@ -0,0 +1,14 @@ +DELETE FROM + credential_email +WHERE + credential_email.email = ? + AND ( + SELECT + COUNT(*) + FROM + account, + credential_steam + WHERE + account.id = credential_email.account_id + AND credential_steam.account_id = account.id + ) > 0; diff --git a/src/unlink_credential/mysql/unlink_credential_steam.sql b/src/unlink_credential/mysql/unlink_credential_steam.sql new file mode 100644 index 0000000..a65eb75 --- /dev/null +++ b/src/unlink_credential/mysql/unlink_credential_steam.sql @@ -0,0 +1,14 @@ +DELETE FROM + credential_steam +WHERE + credential_steam.steamid64 = ? + AND ( + SELECT + COUNT(*) + FROM + account, + credential_email + WHERE + account.id = credential_steam.account_id + AND credential_email.account_id = account.id + ) > 0; diff --git a/src/unlink_credential/queries.rs b/src/unlink_credential/queries.rs new file mode 100644 index 0000000..62beb4b --- /dev/null +++ b/src/unlink_credential/queries.rs @@ -0,0 +1,54 @@ +use account_sql::query::Query; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +pub struct UnlinkCredentialByEmail<'a> { + pub email: &'a email_address::EmailAddress, +} + +#[async_trait] +impl<'a> Query<()> for UnlinkCredentialByEmail<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/unlink_credential_email.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.email.as_str()) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct UnlinkCredentialBySteam<'a> { + pub steamid64: &'a i64, +} + +#[async_trait] +impl<'a> Query<()> for UnlinkCredentialBySteam<'a> { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/unlink_credential_steam.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query().bind(self.steamid64) + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..9eefbaf --- /dev/null +++ b/src/update.rs @@ -0,0 +1,116 @@ +use std::{sync::Arc, time::Duration}; + +use account_sql::query::Query; +use queries::{CleanupAccountTokens, CleanupCerts, CleanupCredentialAuthTokens}; +use sqlx::{Acquire, AnyPool, Executor}; + +use crate::{email::EmailShared, email_limit, ip_limit, shared::Shared}; + +pub mod queries; + +pub async fn update_impl(pool: &AnyPool, shared: &Arc) { + if let Ok(mut connection) = pool.acquire().await { + if let Ok(connection) = connection.acquire().await { + // cleanup credential auth tokens + let _ = connection + .execute( + CleanupCredentialAuthTokens {} + .query(&shared.db.cleanup_credential_auth_tokens_statement), + ) + .await; + + // cleanup account tokens + let _ = connection + .execute(CleanupAccountTokens {}.query(&shared.db.cleanup_account_tokens_statement)) + .await; + + // cleanup certs + let _ = connection + .execute(CleanupCerts {}.query(&shared.db.cleanup_certs_statement)) + .await; + } + } +} + +pub async fn update(pool: AnyPool, shared: Arc) -> ! { + loop { + update_impl(&pool, &shared).await; + + // only do the update once per hour + tokio::time::sleep(Duration::from_secs(60 * 60 * 24)).await; + } +} + +pub async fn handle_watchers(shared: Arc) { + let shared_email_deny = shared.clone(); + let shared_email_allow = shared.clone(); + let shared_email_account_tokens = shared.clone(); + let shared_email_credential_auth_tokens = shared.clone(); + let res = tokio::try_join!( + tokio::spawn(async move { + let mut ip_ban = ip_limit::IpDenyList::watcher(); + loop { + if ip_ban.wait_for_change().await.is_ok() { + let ip_ban_list = ip_limit::IpDenyList::load_from_file().await; + *shared.ip_ban_list.write() = ip_ban_list; + } else { + break; + } + } + }), + tokio::spawn(async move { + let mut email_deny = email_limit::EmailDomainDenyList::watcher(); + loop { + if email_deny.wait_for_change().await.is_ok() { + let deny_list = email_limit::EmailDomainDenyList::load_from_file().await; + *shared_email_deny.email.deny_list.write() = deny_list; + } else { + break; + } + } + }), + tokio::spawn(async move { + let mut email_allow = email_limit::EmailDomainAllowList::watcher(); + loop { + if email_allow.wait_for_change().await.is_ok() { + let allow_list = email_limit::EmailDomainAllowList::load_from_file().await; + *shared_email_allow.email.allow_list.write() = allow_list; + } else { + break; + } + } + }), + tokio::spawn(async move { + let mut email_account_tokens = EmailShared::watcher("account_tokens.html"); + loop { + if email_account_tokens.wait_for_change().await.is_ok() { + if let Ok(mail) = EmailShared::load_email_template("account_tokens.html").await + { + *shared_email_account_tokens.account_tokens_email.write() = Arc::new(mail); + } + } else { + break; + } + } + }), + tokio::spawn(async move { + let mut email_credential_auth = EmailShared::watcher("credential_auth_tokens.html"); + loop { + if email_credential_auth.wait_for_change().await.is_ok() { + if let Ok(mail) = + EmailShared::load_email_template("credential_auth_tokens.html").await + { + *shared_email_credential_auth_tokens + .credential_auth_tokens_email + .write() = Arc::new(mail); + } + } else { + break; + } + } + }), + ); + if let Err(err) = res { + log::error!("{err}"); + } +} diff --git a/src/update/mysql/cleanup_account_tokens.sql b/src/update/mysql/cleanup_account_tokens.sql new file mode 100644 index 0000000..d1dd465 --- /dev/null +++ b/src/update/mysql/cleanup_account_tokens.sql @@ -0,0 +1,4 @@ +DELETE FROM + account_tokens +WHERE + account_tokens.valid_until <= UTC_TIMESTAMP(); diff --git a/src/update/mysql/cleanup_certs.sql b/src/update/mysql/cleanup_certs.sql new file mode 100644 index 0000000..a114955 --- /dev/null +++ b/src/update/mysql/cleanup_certs.sql @@ -0,0 +1,5 @@ +DELETE FROM + certs +WHERE + -- two days earlier, the corresponding keys shouldn't be in use anymore anyway + certs.valid_until <= DATE_ADD(UTC_TIMESTAMP(), INTERVAL 2 DAY); diff --git a/src/update/mysql/cleanup_credential_auth_tokens.sql b/src/update/mysql/cleanup_credential_auth_tokens.sql new file mode 100644 index 0000000..a8cbbed --- /dev/null +++ b/src/update/mysql/cleanup_credential_auth_tokens.sql @@ -0,0 +1,4 @@ +DELETE FROM + credential_auth_tokens +WHERE + credential_auth_tokens.valid_until <= UTC_TIMESTAMP(); diff --git a/src/update/queries.rs b/src/update/queries.rs new file mode 100644 index 0000000..2f78440 --- /dev/null +++ b/src/update/queries.rs @@ -0,0 +1,72 @@ +use account_sql::query::Query; +use anyhow::anyhow; +use axum::async_trait; +use sqlx::any::AnyRow; +use sqlx::Executor; +use sqlx::Statement; + +pub struct CleanupCredentialAuthTokens {} + +#[async_trait] +impl Query<()> for CleanupCredentialAuthTokens { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/cleanup_credential_auth_tokens.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query() + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct CleanupAccountTokens {} + +#[async_trait] +impl Query<()> for CleanupAccountTokens { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/cleanup_account_tokens.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query() + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} + +pub struct CleanupCerts {} + +#[async_trait] +impl Query<()> for CleanupCerts { + async fn prepare_mysql( + connection: &mut sqlx::AnyConnection, + ) -> anyhow::Result> { + Ok(connection + .prepare(include_str!("mysql/cleanup_certs.sql")) + .await?) + } + fn query_mysql<'b>( + &'b self, + statement: &'b sqlx::any::AnyStatement<'static>, + ) -> sqlx::query::Query<'b, sqlx::Any, sqlx::any::AnyArguments<'b>> { + statement.query() + } + fn row_data(_row: &AnyRow) -> anyhow::Result<()> { + Err(anyhow!("Row data is not supported")) + } +} diff --git a/templates/email/a.png b/templates/email/a.png new file mode 100644 index 0000000..62fdadc Binary files /dev/null and b/templates/email/a.png differ diff --git a/templates/email/b1.png b/templates/email/b1.png new file mode 100644 index 0000000..1a94540 Binary files /dev/null and b/templates/email/b1.png differ diff --git a/templates/email/b2.png b/templates/email/b2.png new file mode 100644 index 0000000..c6f59f2 Binary files /dev/null and b/templates/email/b2.png differ diff --git a/templates/email/b3.png b/templates/email/b3.png new file mode 100644 index 0000000..39da4f2 Binary files /dev/null and b/templates/email/b3.png differ diff --git a/templates/email/template.html b/templates/email/template.html new file mode 100644 index 0000000..a790f8a --- /dev/null +++ b/templates/email/template.html @@ -0,0 +1,173 @@ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ DDNet + +
+
+
+
+ Hello %SUBJECT%, +

+ please use the following code to verify your action: +

+ %CODE% + +
+ This code is valid for ~15 minutes. +



+ If this wasn't you, you can safely ignore this e-mail. +
+ Don't share this code with anyone, or else you might lose your account. +
+
+ + + + + +
+
+
+ DDraceNetwork is a free cooperative platformer game. +
+ Visit our + Website + for more information. +
+
+
+
+
+ +