diff options
Diffstat (limited to '')
67 files changed, 2065 insertions, 2641 deletions
@@ -23,6 +23,15 @@ dependencies = [ ] [[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] name = "anyhow" version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -287,6 +296,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] +name = "chrono" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" +dependencies = [ + "iana-time-zone", + "num-integer", + "num-traits", + "serde", + "winapi", +] + +[[package]] name = "chunked_transfer" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -330,6 +352,16 @@ dependencies = [ ] [[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] name = "colored" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -435,6 +467,85 @@ dependencies = [ ] [[package]] +name = "cxx" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add3fc1717409d029b20c5b6903fc0c0b02fa6741d820054f4a2efa5e5816fd" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c87959ba14bc6fbc61df77c3fcfe180fc32b93538c4f1031dd802ccb5f2ff0" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69a3e162fde4e594ed2b07d0f83c6c67b745e7f28ce58c6df5e6b6bef99dfb59" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e7e2adeb6a0d4a282e581096b06e1791532b7d576dcde5ccd9382acf55db8e6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0dd3cd20dc6b5a876612a6e5accfe7f3dd883db6d07acfbf14c128f61550dfa" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a784d2ccaf7c98501746bf0be29b2022ba41fd62a2e622af997a03e9f972859f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7618812407e9402654622dd402b0a89dff9ba93badd6540781526117b92aab7e" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] name = "derive_more" version = "0.99.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -491,6 +602,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" [[package]] +name = "email-encoding" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34dd14c63662e0206599796cd5e1ad0268ab2b9d19b868d6050d688eba2bbf98" +dependencies = [ + "base64", + "memchr", +] + +[[package]] name = "email_address" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -829,6 +950,17 @@ dependencies = [ ] [[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] name = "http" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -913,6 +1045,7 @@ dependencies = [ "async-std", "clap", "colored", + "email_address", "env_logger", "home", "log", @@ -932,6 +1065,47 @@ dependencies = [ ] [[package]] +name = "iana-time-zone" +version = "0.1.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] name = "idna" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -949,6 +1123,7 @@ checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" dependencies = [ "autocfg", "hashbrown", + "serde", ] [[package]] @@ -1028,6 +1203,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] +name = "lettre" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eabca5e0b4d0e98e7f2243fb5b7520b6af2b65d8f87bcc86f2c75185a6ff243" +dependencies = [ + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna 0.2.3", + "mime", + "native-tls", + "nom", + "once_cell", + "quoted_printable", + "socket2", +] + +[[package]] name = "libc" version = "0.2.137" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1045,6 +1242,15 @@ dependencies = [ ] [[package]] +name = "link-cplusplus" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" +dependencies = [ + "cc", +] + +[[package]] name = "linux-raw-sys" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1071,6 +1277,18 @@ dependencies = [ ] [[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] name = "md-5" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1138,6 +1356,16 @@ dependencies = [ ] [[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] name = "num-traits" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1380,6 +1608,12 @@ dependencies = [ ] [[package]] +name = "quoted_printable" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f14e071918cbeefc5edc986a7aa92c425dae244e003a35e1cdddb5ca39b5cb" + +[[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1544,6 +1778,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] +name = "scratch" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" + +[[package]] name = "secd" version = "0.1.0" dependencies = [ @@ -1553,13 +1793,17 @@ dependencies = [ "clap", "derive_more", "email_address", + "hex", "lazy_static", + "lettre", "log", "openssl", "rand", "reqwest", "serde", "serde_json", + "serde_with", + "sha2", "sqlx", "strum", "strum_macros", @@ -1643,6 +1887,34 @@ dependencies = [ ] [[package]] +name = "serde_with" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25bf4a5a814902cd1014dbccfa4d4560fb8432c779471e96e035602519f82eef" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3452b4c0f6c1e357f73fdb87cd1efabaa12acf328c7a528e252893baeb3f4aa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "sha1" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2093,6 +2365,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" [[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] name = "unicode_categories" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2105,7 +2383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" dependencies = [ "form_urlencoded", - "idna", + "idna 0.3.0", "percent-encoding", ] diff --git a/crates/iam/Cargo.toml b/crates/iam/Cargo.toml index ba642c3..966f341 100644 --- a/crates/iam/Cargo.toml +++ b/crates/iam/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0" async-std = { version = "1.12.0", features = [ "attributes" ] } clap = { version = "4.0.29", features = ["derive"] } colored = "2.0.0" +email_address = "0.2" env_logger = "0.9" home = "0.5.4" log = "0.4" diff --git a/crates/iam/src/api.rs b/crates/iam/src/api.rs index 841aa9e..ace3199 100644 --- a/crates/iam/src/api.rs +++ b/crates/iam/src/api.rs @@ -1,7 +1,7 @@ use crate::ISSUE_TRACKER_LOC; use clap::{Parser, Subcommand, ValueEnum}; use colored::*; -use secd::{IdentityId, OauthProviderName}; +use secd::IdentityId; use serde::{Deserialize, Serialize}; use thiserror; use url::Url; @@ -183,7 +183,6 @@ pub enum AdminObject { }, /// A selected Oauth2.0 provider capable of authenticating identities Oauth2Provider { - provider: OauthProviderName, client_id: String, secret: String, redirect_url: Url, @@ -310,7 +309,7 @@ pub enum CreateObject { secret_code: String, }, #[command( - about = "An action which initiates an identity validation", + about = "An action which initiates an address validation", long_about = "Validation\n\nA validation requires that the identity authenticate in some way, either by providing IAM managed credentials, an external gated mechanism (e.g. email, phone, or hardware key), or through a secondary authentication provider (oauth, saml, ldap, kerberos)." )] Validation { @@ -319,7 +318,21 @@ pub enum CreateObject { method: ValidationMethod, /// The identity against which to associate this validation. A new identity will be created if no identity is provided. #[arg(long, short)] - identity: Option<Uuid>, + identity_id: Option<Uuid>, + }, + #[command( + about = "An action which completes an address validation", + long_about = "Validation Completion\n\nA validation completion depends on an existing address validation, which is validated based on the provided validation id and secret token or secret code" + )] + ValidationCompletion { + /// The validation id against which to complete the validation. + validation_id: Uuid, + /// The secret token for the validation. A token or code must be provided. + #[arg(long, short)] + token: Option<String>, + /// The secret code for the validation. A code or token must be provided. + #[arg(long, short)] + code: Option<String>, }, } @@ -343,26 +356,12 @@ pub enum ValidationMethod { /// Email address which will receive the validation address: String, }, - /// A hardware security key to associate with an identity - HardwareKey, - /// A kerberos ticket to associated with an identity - Kerberos, - /// An oauth2 provider to authenticate (and authorize) an identity - Oauth2 { - provider: OauthProviderName, - /// An optional scope to use for authorization - scope: Option<String>, - /// An optional existing identity to link to this validation request - identity: Option<IdentityId>, - }, - /// A phone which an identity may authenticate via SMS or voice + /// A phone which an identity may authenticate via SMS or Voice Phone { /// Whether to use a voice code. Otherwise, uses SMS #[arg(long, short, action)] use_voice: bool, }, - /// A saml provider to authenticate an identity - Saml, } #[derive(Subcommand)] @@ -379,8 +378,8 @@ pub enum GetObject { id: Option<Uuid>, }, Identity { - /// Unique identity id - id: Uuid, + /// Any session corresponding to this identity. + session_token: String, }, Permission { /// Unique permission name @@ -497,7 +496,7 @@ pub struct ConfigProfile { pub name: String, pub store: secd::AuthStore, pub store_conn: String, - pub emailer: secd::AuthEmail, + pub emailer: secd::AuthEmailMessenger, pub email_template_login: Option<String>, pub email_template_signup: Option<String>, } diff --git a/crates/iam/src/command.rs b/crates/iam/src/command.rs index 379e7fb..56734b1 100644 --- a/crates/iam/src/command.rs +++ b/crates/iam/src/command.rs @@ -5,7 +5,7 @@ use crate::{ }; use colored::*; use rand::distributions::{Alphanumeric, DistString}; -use secd::{AuthEmail, AuthStore}; +use secd::{AuthEmailMessenger, AuthStore}; use std::{ fs::{self, File}, io::{self, stdin, stdout, Read, Write}, @@ -48,13 +48,13 @@ pub async fn admin_init(is_interactive: bool) -> Result<()> { let mut cfg = api::Config { profile: vec![api::ConfigProfile { name: "default".to_string(), - store: AuthStore::Sqlite, + store: AuthStore::Sqlite { conn: "".into() }, store_conn: format!( "sqlite://{}/{}.sql?mode=rwc", config_dir.clone().display().to_string(), Alphanumeric.sample_string(&mut rand::thread_rng(), 5), ), - emailer: secd::AuthEmail::LocalStub, + emailer: secd::AuthEmailMessenger::Local, email_template_login: Some(login_template.display().to_string()), email_template_signup: Some(signup_template.display().to_string()), }], @@ -104,7 +104,7 @@ pub async fn admin_init(is_interactive: bool) -> Result<()> { write!( stdout(), "Email provider {:?}: ", - AuthEmail::VARIANTS + AuthEmailMessenger::VARIANTS .iter() .map(|s| s.to_lowercase()) .collect::<Vec<String>>() @@ -112,7 +112,7 @@ pub async fn admin_init(is_interactive: bool) -> Result<()> { stdout().flush()?; input.clear(); stdin().read_line(&mut input)?; - match AuthEmail::from_str(&input.trim()) { + match AuthEmailMessenger::from_str(&input.trim()) { Ok(s) => { cfg.profile[0].emailer = s; break; diff --git a/crates/iam/src/main.rs b/crates/iam/src/main.rs index 4f6316a..ce72072 100644 --- a/crates/iam/src/main.rs +++ b/crates/iam/src/main.rs @@ -10,7 +10,8 @@ use api::{ use clap::Parser; use command::dev_oauth2_listen; use env_logger::Env; -use secd::{Secd, SecdError}; +use secd::{Secd, SecdError, ENV_AUTH_STORE_CONN_STRING}; +use std::str::FromStr; use util::{error_detail, Result}; use uuid::Uuid; @@ -49,16 +50,15 @@ async fn exec() -> Result<Option<String>> { } rest @ _ => { - let cfg = util::read_config(args.profile).map_err(|_| CliError::InvalidProfile)?; - let secd = Secd::init( - cfg.store, - Some(&cfg.store_conn), - cfg.emailer, - cfg.email_template_login, - cfg.email_template_signup, - ) - .await - .map_err(|e| CliError::SecdInitializationFailure(e.to_string()))?; + // let cfg = util::read_config(args.profile).map_err(|_| CliError::InvalidProfile)?; + std::env::set_var( + ENV_AUTH_STORE_CONN_STRING, + "sqlite:///tmp/store.db?mode=rwc", + // "postgresql://secduser:p4ssw0rd@localhost:5412/secd", + ); + let secd = Secd::init() + .await + .map_err(|e| CliError::SecdInitializationFailure(e.to_string()))?; match rest { Command::Admin { action } => admin(&secd, action).await?, @@ -69,13 +69,13 @@ async fn exec() -> Result<Option<String>> { "4a696b66-6231-4a2f-811c-4448a41473d2", "Code path should be unreachable", ))), - Command::Link { object, unlink } => link(&secd, object, unlink).await?, + Command::Link { object, unlink } => todo!(), Command::Ls { object, name, before, after, - } => list(&secd, object, name, before, after).await?, + } => todo!(), Command::Repl => { unimplemented!() } @@ -90,19 +90,7 @@ async fn admin(secd: &Secd, cmd: AdminAction) -> Result<Option<String>> { println!("do backend stuff!"); None } - AdminAction::Create { object } => match object { - AdminObject::Oauth2Provider { - provider, - client_id, - secret, - redirect_url, - } => { - secd.create_oauth_provider(&provider, client_id, secret, redirect_url) - .await?; - None - } - rest @ _ => unimplemented!(), - }, + AdminAction::Create { object } => todo!(), AdminAction::Seal => { println!("do seal"); None @@ -148,54 +136,31 @@ async fn create(secd: &Secd, cmd: CreateObject) -> Result<Option<String>> { CreateObject::Session { validation_id, secret_code, - } => { - let session = secd - .exchange_code_for_session(validation_id, secret_code) - .await - .map_err(|e| match e { - SecdError::InvalidCode => CliError::InvalidCode, - _ => CliError::InternalError(error_detail( - "17e5c226-5d7d-44a2-b3b5-be3ee958c252", - "An unknown error while exchanging a session", - )), - })?; - serde_json::to_string(&session).ok() - } - CreateObject::Validation { method, identity } => match method { - ValidationMethod::Email { address } => serde_json::to_string(&Validation { - validation_id: secd.create_validation_request_email(&address).await?, - note: Some("<secret code> sent to client".into()), - oauth_auth_url: None, - }) - .ok(), + } => todo!(), + CreateObject::Validation { + method, + identity_id, + } => match method { + ValidationMethod::Email { address } => { + let validation = secd.validate_email(&address, identity_id).await?; - ValidationMethod::Oauth2 { - provider, - scope, - identity, - } => { - let redirect = secd - .create_validation_request_oauth(&provider, scope) - .await? - .to_string(); - let validation_id = redirect - .split("state=") - .collect::<Vec<&str>>() - .last() - .map(|i| Uuid::parse_str(i).ok()) - .flatten() - .unwrap(); - serde_json::to_string(&Validation { - validation_id, - note: Some( - "<secret code> is retrieved by completing oauth flow in the browser".into(), - ), - oauth_auth_url: Some(redirect), - }) - .ok() + Some(serde_json::ser::to_string(&validation)?.to_string()) } _ => unimplemented!(), }, + CreateObject::ValidationCompletion { + validation_id, + token, + code, + } => { + if token.is_none() && code.is_none() { + bail!("A token or code must be specified") + } + let session = secd + .complete_address_validation(&validation_id, token, code) + .await?; + Some(serde_json::ser::to_string(&session)?.to_string()) + } }) } @@ -215,10 +180,10 @@ async fn get(secd: &Secd, cmd: GetObject) -> Result<Option<String>> { println!("get object group"); None } - GetObject::Identity { id } => { - println!("get object identity"); - None + GetObject::Identity { session_token } => { + Some(serde_json::ser::to_string(&secd.get_identity(&session_token).await?)?.to_string()) } + GetObject::Permission { name, id } => { println!("get object permission"); None @@ -236,8 +201,7 @@ async fn get(secd: &Secd, cmd: GetObject) -> Result<Option<String>> { None } GetObject::Session { secret } => { - println!("get object session"); - None + Some(serde_json::ser::to_string(&secd.get_session(&secret).await?)?.to_string()) } GetObject::Validation { id } => { println!("get object validation"); diff --git a/crates/secd/Cargo.toml b/crates/secd/Cargo.toml index 069e41e..350cfd1 100644 --- a/crates/secd/Cargo.toml +++ b/crates/secd/Cargo.toml @@ -10,13 +10,17 @@ base64 = "0.13.1" clap = { version = "4.0.29", features = ["derive"] } derive_more = "0.99" email_address = "0.2" +hex = "0.4" lazy_static = "1.4" +lettre = "0.10.1" log = "0.4" openssl = "0.10.42" rand = "0.8" reqwest = { version = "0.11.13", features = ["json"] } serde = "1" serde_json = { version = "1.0", features = ["raw_value"] } +serde_with = { version = "2.1", features = ["hex"] } +sha2 = "0.10.6" strum = "0.24.1" strum_macros = "0.24" sqlx = { path = "../../../sqlx", features = [ "runtime-async-std-native-tls", "postgres", "uuid", "sqlite", "time" ] } diff --git a/crates/secd/README.md b/crates/secd/README.md new file mode 100644 index 0000000..5786d0c --- /dev/null +++ b/crates/secd/README.md @@ -0,0 +1,54 @@ +// maybe motif instead of thread? + +// Email Address validation example +thread = start_thread(EmailAddressValidation, "b@g.com"); +thread = advance_thread(AddressValidation, token, code); +session = complete_thread(thread.id); + +// Sms validation example +thread = start_thread(SmsAddressValidation, "12133447460"); +thread = advance_thread(SmsAddressValidation, token, code); + +// New passphrase +credential = create_credential(Passphrase, "b@g.com", "p4ssw0rd"); +thread = start_thread(Passphrase, "b@g.com", "p4ssw0rd"); +session = complete_thread(thread.id); + +// New Totp +credential = create_credential(Totp); +thread = start_thread(Totp, code); +session = complete_thread(thread.id); + +// New OneTimeCodes +credential = create_credential(OneTimeCode); +thread = start_thread(OneTimeCodes, code); +session = complete_thread(thread.id); + +// MFA example which requires totp after email +thread = start_thread(Passphrase, "b@g.com", "p4ssw0rd"); +Thread { Proof: { credential: [totp] } } +thread = advance_thread(Totp, code); +session = complete_thread(thread.id); + + +// REST entities +Identity +Credential +Address +Motif +Session + +// example +POST /api/auth/email-validation +motif = start_motif(EmailAddress, "b@g.com", None) +--> an email has been sent with this motif.id + code and stuff +user clicks on email +GET /api/auth/email-validation/complete?motif_id=1234 +session = complete_thread(motif_id) + +under the hood, it looks up the thread_id, sees that it belongs to an email validation, validates the email, creates a new identity if it's not already attached, creates a session and returns that session. + +GET /oidc/provider?state=123444 -- state validated by client +POST /api/auth/oidc { data ... } +motif = start_motif(Oidc, access_token, data) +session = complete_motif(motif.id) diff --git a/crates/secd/src/client/email.rs b/crates/secd/src/client/email.rs deleted file mode 100644 index 2712037..0000000 --- a/crates/secd/src/client/email.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::{path::PathBuf, str::FromStr}; - -use email_address::EmailAddress; -use time::OffsetDateTime; - -use super::{ - EmailMessenger, EmailMessengerError, EmailType, EMAIL_TEMPLATE_DEFAULT_LOGIN, - EMAIL_TEMPLATE_DEFAULT_SIGNUP, -}; - -pub(crate) struct LocalEmailStubber { - pub(crate) email_template_login: Option<String>, - pub(crate) email_template_signup: Option<String>, -} - -#[async_trait::async_trait] -impl EmailMessenger for LocalEmailStubber { - // TODO: this module really shouldn't be called client, it should be called services... the client is sqlx/mailgun/sns wrapper or whatever... - async fn send_email( - &self, - email_address: &str, - validation_id: &str, - secret_code: &str, - t: EmailType, - ) -> Result<(), EmailMessengerError> { - let login_template = self - .email_template_login - .clone() - .unwrap_or(EMAIL_TEMPLATE_DEFAULT_LOGIN.to_string()); - let signup_template = self - .email_template_signup - .clone() - .unwrap_or(EMAIL_TEMPLATE_DEFAULT_SIGNUP.to_string()); - - let replace_template = |s: &str| { - s.replace( - "%secd_link%", - &format!("{}?code={}", validation_id, secret_code), - ) - .replace("%secd_email_address%", email_address) - .replace("%secd_code%", secret_code) - }; - - if !EmailAddress::is_valid(email_address) { - return Err(EmailMessengerError::InvalidEmailAddress); - } - - let body = match t { - EmailType::Login => replace_template(&login_template), - EmailType::Signup => replace_template(&signup_template), - }; - - // TODO: write to the system mailbox instead? - std::fs::write( - PathBuf::from_str(&format!( - "/tmp/{}_{}.localmail", - OffsetDateTime::now_utc(), - validation_id - )) - .map_err(|_| EmailMessengerError::Unknown)?, - body, - ) - .map_err(|_| EmailMessengerError::FailedToSendEmail)?; - - Ok(()) - } -} diff --git a/crates/secd/src/client/email/mod.rs b/crates/secd/src/client/email/mod.rs new file mode 100644 index 0000000..915d18c --- /dev/null +++ b/crates/secd/src/client/email/mod.rs @@ -0,0 +1,68 @@ +use email_address::EmailAddress; +use lettre::Transport; +use log::error; +use std::collections::HashMap; + +#[derive(Debug, thiserror::Error, derive_more::Display)] +pub enum EmailMessengerError { + FailedToSendEmail, +} + +pub struct EmailValidationMessage { + pub recipient: EmailAddress, + pub subject: String, + pub body: String, +} + +#[async_trait::async_trait] +pub(crate) trait EmailMessenger { + async fn send_email( + &self, + email_address: &EmailAddress, + template: &str, + template_vars: HashMap<&str, &str>, + ) -> Result<(), EmailMessengerError>; +} + +pub(crate) struct LocalMailer {} + +#[async_trait::async_trait] +impl EmailMessenger for LocalMailer { + async fn send_email( + &self, + email_address: &EmailAddress, + template: &str, + template_vars: HashMap<&str, &str>, + ) -> Result<(), EmailMessengerError> { + todo!() + } +} + +#[async_trait::async_trait] +pub(crate) trait Sendable { + async fn send(&self) -> Result<(), EmailMessengerError>; +} + +#[async_trait::async_trait] +impl Sendable for EmailValidationMessage { + // TODO: We need to break this up as before, especially so we can feature + // gate unwanted things like Lettre... + async fn send(&self) -> Result<(), EmailMessengerError> { + // TODO: Get these things from the template... + let email = lettre::Message::builder() + .from("BranchControl <iam@branchcontrol.com>".parse().unwrap()) + .reply_to("BranchControl <iam@branchcontrol.com>".parse().unwrap()) + .to(self.recipient.to_string().parse().unwrap()) + .subject(self.subject.clone()) + .body(self.body.clone()) + .unwrap(); + + let mailer = lettre::SmtpTransport::unencrypted_localhost(); + + mailer.send(&email).map_err(|e| { + error!("failed to send email {:?}", e); + EmailMessengerError::FailedToSendEmail + })?; + Ok(()) + } +} diff --git a/crates/secd/src/client/mod.rs b/crates/secd/src/client/mod.rs index 38426ef..e5272fd 100644 --- a/crates/secd/src/client/mod.rs +++ b/crates/secd/src/client/mod.rs @@ -1,422 +1,2 @@ pub(crate) mod email; -pub(crate) mod sqldb; -pub(crate) mod types; - -use std::{collections::HashMap, str::FromStr}; - -use super::Identity; -use crate::{ - EmailValidation, OauthProvider, OauthProviderName, OauthResponseType, OauthValidation, Session, - SessionSecret, ValidationRequestId, ValidationType, -}; - -use email_address::EmailAddress; -use lazy_static::lazy_static; -use sqlx::{ - database::HasValueRef, sqlite::SqliteRow, ColumnIndex, Database, Decode, FromRow, Row, Sqlite, - Type, -}; -use thiserror::Error; -use time::OffsetDateTime; -use url::Url; -use uuid::Uuid; - -pub enum EmailType { - Login, - Signup, -} - -#[derive(Error, Debug, derive_more::Display)] -pub enum EmailMessengerError { - InvalidEmailAddress, - FailedToSendEmail, - Unknown, -} - -#[async_trait::async_trait] -pub trait EmailMessenger { - async fn send_email( - &self, - email_address: &str, - validation_id: &str, - secret_code: &str, - t: EmailType, - ) -> Result<(), EmailMessengerError>; -} - -#[derive(Error, Debug, derive_more::Display)] -pub enum StoreError { - SqlxError(#[from] sqlx::Error), - CodeAppearsMoreThanOnce, - CodeDoesNotExist(String), - IdentityIdMustExistInvariant, - TooManyValidations, - TooManyIdentitiesFound, - NoEmailValidationFound, - OauthProviderDoesNotExist(OauthProviderName), - OauthValidationDoesNotExist(ValidationRequestId), - Other(String), -} - -const EMAIL_TEMPLATE_DEFAULT_LOGIN: &str = "You requested a login link. Please click the following link %secd_code% to login as %secd_email_address%"; -const EMAIL_TEMPLATE_DEFAULT_SIGNUP: &str = "You requested a sign up. Please click the following link %secd_code% to complete your sign up and validate %secd_email_address%"; - -const ERR_MSG_MIGRATION_FAILED: &str = "Failed to execute migrations. This appears to be a secd issue. File a bug at https://www.github.com/secd-lib"; - -const SQLITE: &str = "sqlite"; -const PGSQL: &str = "pgsql"; - -const WRITE_IDENTITY: &str = "write_identity"; -const WRITE_EMAIL_VALIDATION: &str = "write_email_validation"; -const FIND_EMAIL_VALIDATION: &str = "find_email_validation"; -const READ_VALIDATION_TYPE: &str = "read_validation_type"; - -const WRITE_EMAIL: &str = "write_email"; - -const READ_IDENTITY: &str = "read_identity"; -const FIND_IDENTITY: &str = "find_identity"; -const FIND_IDENTITY_BY_CODE: &str = "find_identity_by_code"; - -const READ_IDENTITY_RAW_ID: &str = "read_identity_raw_id"; -const READ_EMAIL_RAW_ID: &str = "read_email_raw_id"; - -const WRITE_SESSION: &str = "write_session"; -const READ_SESSION: &str = "read_session"; - -const WRITE_OAUTH_PROVIDER: &str = "write_oauth_provider"; -const READ_OAUTH_PROVIDER: &str = "read_oauth_provider"; -const WRITE_OAUTH_VALIDATION: &str = "write_oauth_validation"; -const READ_OAUTH_VALIDATION: &str = "read_oauth_validation"; - -lazy_static! { - static ref SQLS: HashMap<&'static str, HashMap<&'static str, &'static str>> = { - let sqlite_sqls: HashMap<&'static str, &'static str> = [ - ( - WRITE_IDENTITY, - include_str!("../../store/sqlite/sql/write_identity.sql"), - ), - ( - WRITE_EMAIL_VALIDATION, - include_str!("../../store/sqlite/sql/write_email_validation.sql"), - ), - ( - WRITE_EMAIL, - include_str!("../../store/sqlite/sql/write_email.sql"), - ), - ( - READ_IDENTITY, - include_str!("../../store/sqlite/sql/read_identity.sql"), - ), - ( - FIND_IDENTITY, - include_str!("../../store/sqlite/sql/find_identity.sql"), - ), - ( - FIND_IDENTITY_BY_CODE, - include_str!("../../store/sqlite/sql/find_identity_by_code.sql"), - ), - ( - READ_IDENTITY_RAW_ID, - include_str!("../../store/sqlite/sql/read_identity_raw_id.sql"), - ), - ( - READ_EMAIL_RAW_ID, - include_str!("../../store/sqlite/sql/read_email_raw_id.sql"), - ), - ( - WRITE_SESSION, - include_str!("../../store/sqlite/sql/write_session.sql"), - ), - ( - READ_SESSION, - include_str!("../../store/sqlite/sql/read_session.sql"), - ), - ( - FIND_EMAIL_VALIDATION, - include_str!("../../store/sqlite/sql/find_email_validation.sql"), - ), - ( - WRITE_OAUTH_PROVIDER, - include_str!("../../store/sqlite/sql/write_oauth_provider.sql"), - ), - ( - READ_OAUTH_PROVIDER, - include_str!("../../store/sqlite/sql/read_oauth_provider.sql"), - ), - ( - READ_OAUTH_VALIDATION, - include_str!("../../store/sqlite/sql/read_oauth_validation.sql"), - ), - ( - WRITE_OAUTH_VALIDATION, - include_str!("../../store/sqlite/sql/write_oauth_validation.sql"), - ), - ( - READ_VALIDATION_TYPE, - include_str!("../../store/sqlite/sql/read_validation_type.sql"), - ), - ] - .iter() - .cloned() - .collect(); - - let pg_sqls: HashMap<&'static str, &'static str> = [ - ( - WRITE_IDENTITY, - include_str!("../../store/pg/sql/write_identity.sql"), - ), - ( - WRITE_EMAIL_VALIDATION, - include_str!("../../store/pg/sql/write_email_validation.sql"), - ), - ( - WRITE_EMAIL, - include_str!("../../store/pg/sql/write_email.sql"), - ), - ( - READ_IDENTITY, - include_str!("../../store/pg/sql/read_identity.sql"), - ), - ( - FIND_IDENTITY, - include_str!("../../store/pg/sql/find_identity.sql"), - ), - ( - FIND_IDENTITY_BY_CODE, - include_str!("../../store/pg/sql/find_identity_by_code.sql"), - ), - ( - READ_IDENTITY_RAW_ID, - include_str!("../../store/pg/sql/read_identity_raw_id.sql"), - ), - ( - READ_EMAIL_RAW_ID, - include_str!("../../store/pg/sql/read_email_raw_id.sql"), - ), - ( - WRITE_SESSION, - include_str!("../../store/pg/sql/write_session.sql"), - ), - ( - READ_SESSION, - include_str!("../../store/pg/sql/read_session.sql"), - ), - ( - FIND_EMAIL_VALIDATION, - include_str!("../../store/pg/sql/find_email_validation.sql"), - ), - ( - WRITE_OAUTH_PROVIDER, - include_str!("../../store/pg/sql/write_oauth_provider.sql"), - ), - ( - READ_OAUTH_PROVIDER, - include_str!("../../store/pg/sql/read_oauth_provider.sql"), - ), - ( - READ_OAUTH_VALIDATION, - include_str!("../../store/pg/sql/read_oauth_validation.sql"), - ), - ( - WRITE_OAUTH_VALIDATION, - include_str!("../../store/pg/sql/write_oauth_validation.sql"), - ), - ( - READ_VALIDATION_TYPE, - include_str!("../../store/pg/sql/read_validation_type.sql"), - ), - ] - .iter() - .cloned() - .collect(); - - let sqls: HashMap<&'static str, HashMap<&'static str, &'static str>> = - [(SQLITE, sqlite_sqls), (PGSQL, pg_sqls)] - .iter() - .cloned() - .collect(); - sqls - }; -} - -impl<'a, R: Row> FromRow<'a, R> for OauthValidation -where - &'a str: ColumnIndex<R>, - OauthProviderName: Decode<'a, R::Database> + Type<R::Database>, - OauthResponseType: Decode<'a, R::Database> + Type<R::Database>, - OffsetDateTime: Decode<'a, R::Database> + Type<R::Database>, - String: Decode<'a, R::Database> + Type<R::Database>, - Uuid: Decode<'a, R::Database> + Type<R::Database>, -{ - fn from_row(row: &'a R) -> Result<Self, sqlx::Error> { - let id: Option<Uuid> = row.try_get("oauth_validation_public_id")?; - let identity_id: Option<Uuid> = row.try_get("identity_public_id")?; - let access_token: Option<String> = row.try_get("access_token")?; - let raw_response: Option<String> = row.try_get("raw_response")?; - let created_at: Option<OffsetDateTime> = row.try_get("created_at")?; - let validated_at: Option<OffsetDateTime> = row.try_get("validated_at")?; - let revoked_at: Option<OffsetDateTime> = row.try_get("revoked_at")?; - let deleted_at: Option<OffsetDateTime> = row.try_get("deleted_at")?; - - let op_name: Option<OauthProviderName> = row.try_get("oauth_provider_name")?; - let op_flow: Option<String> = row.try_get("oauth_provider_flow")?; - let op_base_url: Option<String> = row.try_get("oauth_provider_base_url")?; - let op_response_type: Option<OauthResponseType> = - row.try_get("oauth_provider_response_type")?; - let op_default_scope: Option<String> = row.try_get("oauth_provider_default_scope")?; - let op_client_id: Option<String> = row.try_get("oauth_provider_client_id")?; - let op_client_secret: Option<String> = row.try_get("oauth_provider_client_secret")?; - let op_redirect_url: Option<String> = row.try_get("oauth_provider_redirect_url")?; - let op_created_at: Option<OffsetDateTime> = row.try_get("oauth_provider_created_at")?; - let op_deleted_at: Option<OffsetDateTime> = row.try_get("oauth_provider_deleted_at")?; - - let op_base_url = op_base_url - .map(|s| Url::from_str(&s).ok()) - .flatten() - .ok_or(sqlx::Error::ColumnDecode { - index: "oauth_provider_base_url".into(), - source: "secd".into(), - })?; - - let op_redirect_url = op_redirect_url - .map(|s| Url::from_str(&s).ok()) - .flatten() - .ok_or(sqlx::Error::ColumnDecode { - index: "oauth_provider_redirect_url".into(), - source: "secd".into(), - })?; - - Ok(OauthValidation { - id, - identity_id, - access_token, - raw_response, - created_at: created_at.ok_or(sqlx::Error::ColumnDecode { - index: "created_at".into(), - source: "secd".into(), - })?, - validated_at, - revoked_at, - deleted_at, - oauth_provider: OauthProvider { - name: op_name.unwrap(), - flow: op_flow, - base_url: op_base_url, - response: op_response_type.ok_or(sqlx::Error::ColumnDecode { - index: "oauth_provider_response_type".into(), - source: "secd".into(), - })?, - default_scope: op_default_scope.ok_or(sqlx::Error::ColumnDecode { - index: "oauth_provider_default_scope".into(), - source: "secd".into(), - })?, - client_id: op_client_id.ok_or(sqlx::Error::ColumnDecode { - index: "oauth_provider_client_id".into(), - source: "secd".into(), - })?, - client_secret: op_client_secret.ok_or(sqlx::Error::ColumnDecode { - index: "oauth_provider_client_secret".into(), - source: "secd".into(), - })?, - redirect_url: op_redirect_url, - created_at: op_created_at.ok_or(sqlx::Error::ColumnDecode { - index: "oauth_provider_created_at".into(), - source: "secd".into(), - })?, - deleted_at: op_deleted_at, - }, - }) - } -} - -impl<'a, D: Database> Decode<'a, D> for OauthProviderName -where - &'a str: Decode<'a, D>, -{ - fn decode( - value: <D as HasValueRef<'a>>::ValueRef, - ) -> Result<Self, Box<dyn ::std::error::Error + 'static + Send + Sync>> { - let v = <&str as Decode<D>>::decode(value)?; - <OauthProviderName as clap::ValueEnum>::from_str(v, true) - .map_err(|_| "OauthProviderName should exist and decode to a program value.".into()) - } -} - -impl<D: Database> Type<D> for OauthProviderName -where - str: Type<D>, -{ - fn type_info() -> D::TypeInfo { - <&str as Type<D>>::type_info() - } -} - -impl<'a, D: Database> Decode<'a, D> for OauthResponseType -where - &'a str: Decode<'a, D>, -{ - fn decode( - value: <D as HasValueRef<'a>>::ValueRef, - ) -> Result<Self, Box<dyn ::std::error::Error + 'static + Send + Sync>> { - let v = <&str as Decode<D>>::decode(value)?; - <OauthResponseType as clap::ValueEnum>::from_str(v, true) - .map_err(|_| "OauthResponseType should exist and decode to a program value.".into()) - } -} - -impl<D: Database> Type<D> for OauthResponseType -where - str: Type<D>, -{ - fn type_info() -> D::TypeInfo { - <&str as Type<D>>::type_info() - } -} - -#[async_trait::async_trait] -pub trait Store { - async fn write_email(&self, email_address: &str) -> Result<(), StoreError>; - - async fn find_email_validation( - &self, - validation_id: Option<&Uuid>, - code: Option<&str>, - ) -> Result<EmailValidation, StoreError>; - async fn write_email_validation( - &self, - ev: &EmailValidation, - // TODO: Make this write an EmailValidation - ) -> anyhow::Result<Uuid>; - - async fn find_identity( - &self, - identity_id: Option<&Uuid>, - email: Option<&str>, - ) -> anyhow::Result<Option<Identity>>; - async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError>; - async fn write_identity(&self, i: &Identity) -> Result<(), StoreError>; - async fn read_identity(&self, identity_id: &Uuid) -> Result<Identity, StoreError>; - - async fn write_session(&self, session: &Session) -> Result<(), StoreError>; - async fn read_session(&self, secret: &SessionSecret) -> Result<Session, StoreError>; - - async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError>; - async fn read_oauth_provider( - &self, - provider: &OauthProviderName, - flow: Option<String>, - ) -> Result<OauthProvider, StoreError>; - async fn write_oauth_validation( - &self, - validation: &OauthValidation, - ) -> anyhow::Result<ValidationRequestId>; - async fn read_oauth_validation( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<OauthValidation>; - - async fn find_validation_type( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<ValidationType>; -} +pub(crate) mod store; diff --git a/crates/secd/src/client/sqldb.rs b/crates/secd/src/client/sqldb.rs deleted file mode 100644 index 6751ef6..0000000 --- a/crates/secd/src/client/sqldb.rs +++ /dev/null @@ -1,632 +0,0 @@ -use std::{str::FromStr, sync::Arc}; - -use super::{ - EmailValidation, Identity, OauthProvider, OauthProviderName, OauthResponseType, Session, - SessionSecret, Store, StoreError, ERR_MSG_MIGRATION_FAILED, FIND_EMAIL_VALIDATION, - FIND_IDENTITY, FIND_IDENTITY_BY_CODE, PGSQL, READ_EMAIL_RAW_ID, READ_IDENTITY_RAW_ID, - READ_OAUTH_PROVIDER, READ_OAUTH_VALIDATION, READ_SESSION, READ_VALIDATION_TYPE, SQLITE, SQLS, - WRITE_EMAIL, WRITE_EMAIL_VALIDATION, WRITE_IDENTITY, WRITE_OAUTH_PROVIDER, - WRITE_OAUTH_VALIDATION, WRITE_SESSION, -}; -use crate::{util, OauthValidation, ValidationRequestId, ValidationType}; -use anyhow::bail; -use log::{debug, error}; -use openssl::sha::Sha256; -use sqlx::{ - self, database::HasArguments, ColumnIndex, Database, Decode, Encode, Executor, IntoArguments, - Pool, Postgres, Sqlite, Transaction, Type, -}; -use time::OffsetDateTime; -use url::Url; -use uuid::Uuid; - -fn get_sqls(root: &str, file: &str) -> Vec<String> { - SQLS.get(root) - .unwrap() - .get(file) - .unwrap() - .split("--") - .map(|p| p.to_string()) - .collect() -} - -fn hash_secret(secret: &str) -> Vec<u8> { - let mut hasher = Sha256::new(); - hasher.update(secret.as_bytes()); - hasher.finish().to_vec() -} - -struct SqlClient<D> -where - D: sqlx::Database, -{ - pool: sqlx::Pool<D>, - sqls_root: String, -} - -impl<D> SqlClient<D> -where - D: sqlx::Database, - for<'c> <D as HasArguments<'c>>::Arguments: IntoArguments<'c, D>, - for<'c> i64: Decode<'c, D> + Type<D>, - for<'c> &'c str: Decode<'c, D> + Type<D>, - for<'c> &'c str: Encode<'c, D> + Type<D>, - for<'c> usize: ColumnIndex<<D as Database>::Row>, - for<'c> Uuid: Decode<'c, D> + Type<D>, - for<'c> Uuid: Encode<'c, D> + Type<D>, - for<'c> &'c Pool<D>: Executor<'c, Database = D>, -{ - async fn read_identity_raw_id(&self, id: &Uuid) -> Result<i64, StoreError> { - let sqls = get_sqls(&self.sqls_root, READ_IDENTITY_RAW_ID); - - Ok(sqlx::query_as::<_, (i64,)>(&sqls[0]) - .bind(id) - .fetch_one(&self.pool) - .await - .map_err(util::log_err_sqlx)? - .0) - } - - async fn read_email_raw_id(&self, address: &str) -> Result<i64, StoreError> { - let sqls = get_sqls(&self.sqls_root, READ_EMAIL_RAW_ID); - - Ok(sqlx::query_as::<_, (i64,)>(&sqls[0]) - .bind(address) - .fetch_one(&self.pool) - .await - .map_err(util::log_err_sqlx)? - .0) - } -} - -#[async_trait::async_trait] -impl<D> Store for SqlClient<D> -where - D: sqlx::Database, - for<'c> <D as HasArguments<'c>>::Arguments: IntoArguments<'c, D>, - for<'c> bool: Decode<'c, D> + Type<D>, - for<'c> bool: Encode<'c, D> + Type<D>, - for<'c> i64: Decode<'c, D> + Type<D>, - for<'c> i64: Encode<'c, D> + Type<D>, - for<'c> i32: Decode<'c, D> + Type<D>, - for<'c> i32: Encode<'c, D> + Type<D>, - for<'c> OffsetDateTime: Decode<'c, D> + Type<D>, - for<'c> OffsetDateTime: Encode<'c, D> + Type<D>, - for<'c> &'c str: ColumnIndex<<D as Database>::Row>, - for<'c> &'c str: Decode<'c, D> + Type<D>, - for<'c> &'c str: Encode<'c, D> + Type<D>, - for<'c> Option<&'c str>: Decode<'c, D> + Type<D>, - for<'c> Option<&'c str>: Encode<'c, D> + Type<D>, - for<'c> String: Decode<'c, D> + Type<D>, - for<'c> String: Encode<'c, D> + Type<D>, - for<'c> Option<String>: Decode<'c, D> + Type<D>, - for<'c> Option<String>: Encode<'c, D> + Type<D>, - for<'c> OauthProviderName: Decode<'c, D> + Type<D>, - for<'c> OauthResponseType: Decode<'c, D> + Type<D>, - for<'c> usize: ColumnIndex<<D as Database>::Row>, - for<'c> Uuid: Decode<'c, D> + Type<D>, - for<'c> Uuid: Encode<'c, D> + Type<D>, - for<'c> &'c [u8]: Encode<'c, D> + Type<D>, - for<'c> Option<&'c Uuid>: Encode<'c, D> + Type<D>, - for<'c> Option<&'c Vec<u8>>: Encode<'c, D> + Type<D>, - for<'c> Option<OffsetDateTime>: Decode<'c, D> + Type<D>, - for<'c> Option<OffsetDateTime>: Encode<'c, D> + Type<D>, - for<'c> &'c Pool<D>: Executor<'c, Database = D>, - for<'c> &'c mut Transaction<'c, D>: Executor<'c, Database = D>, -{ - async fn write_email(&self, email_address: &str) -> Result<(), StoreError> { - let sqls = get_sqls(&self.sqls_root, WRITE_EMAIL); - - sqlx::query(&sqls[0]) - .bind(email_address) - .execute(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - Ok(()) - } - - async fn find_email_validation( - &self, - validation_id: Option<&Uuid>, - code: Option<&str>, - ) -> Result<EmailValidation, StoreError> { - let sqls = get_sqls(&self.sqls_root, FIND_EMAIL_VALIDATION); - let mut rows = sqlx::query_as::<_, EmailValidation>(&sqls[0]) - .bind(validation_id) - .bind(code) - .fetch_all(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - match rows.len() { - 0 => Err(StoreError::NoEmailValidationFound), - 1 => Ok(rows.swap_remove(0)), - _ => Err(StoreError::TooManyValidations), - } - } - - async fn write_email_validation(&self, ev: &EmailValidation) -> anyhow::Result<Uuid> { - let sqls = get_sqls(&self.sqls_root, WRITE_EMAIL_VALIDATION); - - let email_id = self.read_email_raw_id(&ev.email_address).await?; - let validation_id = ev.id.unwrap_or(Uuid::new_v4()); - sqlx::query(&sqls[0]) - .bind(validation_id) - .bind(email_id) - .bind(&ev.code) - .bind(ev.is_oauth_derived) - .bind(ev.created_at) - .bind(ev.validated_at) - .bind(ev.expired_at) - .execute(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - if ev.identity_id.is_some() || ev.revoked_at.is_some() || ev.deleted_at.is_some() { - sqlx::query(&sqls[1]) - .bind(ev.identity_id.as_ref()) - .bind(validation_id) - .bind(ev.revoked_at) - .bind(ev.deleted_at) - .execute(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - } - - Ok(validation_id) - } - - async fn find_identity( - &self, - id: Option<&Uuid>, - email: Option<&str>, - ) -> anyhow::Result<Option<Identity>> { - let sqls = get_sqls(&self.sqls_root, FIND_IDENTITY); - Ok( - match sqlx::query_as::<_, Identity>(&sqls[0]) - .bind(id) - .bind(email) - .fetch_all(&self.pool) - .await - { - Ok(mut is) => match is.len() { - // if only 1 found, then that's fine - // if multiple are fond, then if they all have the same id, that's okay - 1 => { - let i = is.swap_remove(0); - match i.deleted_at { - Some(t) if t > OffsetDateTime::now_utc() => Some(i), - None => Some(i), - _ => None, - } - } - 0 => None, - _ => { - match is - .iter() - .filter(|&i| i.id != is[0].id) - .collect::<Vec<&Identity>>() - .len() - { - 0 => Some(is.swap_remove(0)), - _ => bail!(StoreError::TooManyIdentitiesFound), - } - } - }, - Err(sqlx::Error::RowNotFound) => None, - Err(e) => bail!(StoreError::SqlxError(e)), - }, - ) - } - - async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError> { - let sqls = get_sqls(&self.sqls_root, FIND_IDENTITY_BY_CODE); - - let rows = sqlx::query_as::<_, (i32,)>(&sqls[0]) - .bind(code) - .fetch_all(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - if rows.len() == 0 { - return Err(StoreError::CodeDoesNotExist(code.to_string())); - } - - if rows.len() != 1 { - return Err(StoreError::CodeAppearsMoreThanOnce); - } - - let identity_email_id = rows.get(0).unwrap().0; - - // TODO: IF we expand beyond email codes, then we'll need to join against a bunch of identity tables. - // but since a single code was found, only one of them should pop... - Ok(sqlx::query_as::<_, Identity>(&sqls[1]) - .bind(identity_email_id) - .fetch_one(&self.pool) - .await - .map_err(util::log_err_sqlx)?) - } - - async fn write_identity(&self, i: &Identity) -> Result<(), StoreError> { - let sqls = get_sqls(&self.sqls_root, WRITE_IDENTITY); - sqlx::query(&sqls[0]) - .bind(i.id) - .bind(i.data.clone()) - .bind(i.created_at) - .execute(&self.pool) - .await - .map_err(|e| { - error!("write_identity_failure"); - error!("{:?}", e); - e - })?; - - Ok(()) - } - async fn read_identity(&self, id: &Uuid) -> Result<Identity, StoreError> { - let identity = sqlx::query_as::<_, Identity>( - " -select identity_public_id, data, created_at from identity where identity_public_id = ?", - ) - .bind(id) - .fetch_one(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - Ok(identity) - } - - async fn write_session(&self, session: &Session) -> Result<(), StoreError> { - let sqls = get_sqls(&self.sqls_root, WRITE_SESSION); - - let secret_hash = session.secret.as_ref().map(|s| hash_secret(s)); - - sqlx::query(&sqls[0]) - .bind(&session.identity_id) - .bind(secret_hash.as_ref()) - .bind(session.created_at) - .bind(session.expired_at) - .bind(session.revoked_at) - .execute(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - Ok(()) - } - async fn read_session(&self, secret: &SessionSecret) -> Result<Session, StoreError> { - let sqls = get_sqls(&self.sqls_root, READ_SESSION); - - let secret_hash = hash_secret(secret); - let mut session = sqlx::query_as::<_, Session>(&sqls[0]) - .bind(&secret_hash[..]) - .fetch_one(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - // This should do nothing other than updated touched_at, and then - // clear the plaintext secret - session.secret = Some(secret.to_string()); - self.write_session(&session).await?; - session.secret = None; - - Ok(session) - } - - async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError> { - let sqls = get_sqls(&self.sqls_root, WRITE_OAUTH_PROVIDER); - sqlx::query(&sqls[0]) - .bind(&provider.name.to_string()) - .bind(&provider.flow) - .bind(&provider.base_url.to_string()) - .bind(&provider.response.to_string()) - .bind(&provider.default_scope) - .bind(&provider.client_id) - // TODO: encrypt secret before writing - .bind(&provider.client_secret) - .bind(&provider.redirect_url.to_string()) - .bind(provider.created_at) - .bind(provider.deleted_at) - .execute(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - Ok(()) - } - - async fn read_oauth_provider( - &self, - provider: &OauthProviderName, - flow: Option<String>, - ) -> Result<OauthProvider, StoreError> { - let sqls = get_sqls(&self.sqls_root, READ_OAUTH_PROVIDER); - let flow = flow.unwrap_or("default".into()); - debug!("provider: {:?}, flow: {:?}", provider, flow); - // TODO: Write the generic FromRow impl for OauthProvider... - let res = sqlx::query_as::< - _, - ( - String, - String, - String, - String, - String, - String, - String, - OffsetDateTime, - Option<OffsetDateTime>, - ), - >(&sqls[0]) - .bind(&provider.to_string()) - .bind(&flow) - .fetch_one(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - debug!("res: {:?}", res); - - Ok(OauthProvider { - name: provider.clone(), - flow: Some(res.0), - base_url: Url::from_str(&res.1) - .map_err(|_| StoreError::OauthProviderDoesNotExist(*provider))?, - response: OauthResponseType::from_str(&res.2) - .map_err(|_| StoreError::OauthProviderDoesNotExist(*provider))?, - default_scope: res.3, - client_id: res.4, - client_secret: res.5, - redirect_url: Url::from_str(&res.6) - .map_err(|_| StoreError::OauthProviderDoesNotExist(*provider))?, - created_at: res.7, - deleted_at: res.8, - }) - } - async fn write_oauth_validation( - &self, - v: &OauthValidation, - ) -> anyhow::Result<ValidationRequestId> { - let sqls = get_sqls(&self.sqls_root, WRITE_OAUTH_VALIDATION); - - let validation_id = v.id.unwrap_or(Uuid::new_v4()); - sqlx::query(&sqls[0]) - .bind(validation_id) - .bind(v.oauth_provider.name.to_string()) - .bind(v.oauth_provider.flow.clone()) - .bind(v.access_token.clone()) - .bind(v.raw_response.clone()) - .bind(v.created_at) - .bind(v.validated_at) - .execute(&self.pool) - .await?; - - if v.identity_id.is_some() || v.revoked_at.is_some() || v.deleted_at.is_some() { - sqlx::query(&sqls[1]) - .bind(v.identity_id.as_ref()) - .bind(validation_id) - .bind(v.revoked_at) - .bind(v.deleted_at) - .execute(&self.pool) - .await?; - } - - Ok(validation_id) - } - async fn read_oauth_validation( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<OauthValidation> { - let sqls = get_sqls(&self.sqls_root, READ_OAUTH_VALIDATION); - - let mut es = sqlx::query_as::<_, OauthValidation>(&sqls[0]) - .bind(validation_id) - .fetch_all(&self.pool) - .await?; - - if es.len() != 1 { - bail!(StoreError::OauthValidationDoesNotExist( - validation_id.clone() - )); - } - - Ok(es.swap_remove(0)) - } - async fn find_validation_type( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<ValidationType> { - let sqls = get_sqls(&self.sqls_root, READ_VALIDATION_TYPE); - - let mut es = sqlx::query_as::<_, (String,)>(&sqls[0]) - .bind(validation_id) - .fetch_all(&self.pool) - .await - .map_err(util::log_err_sqlx)?; - - match es.len() { - 1 => Ok(ValidationType::from_str(&es.swap_remove(0).0)?), - _ => bail!(StoreError::Other( - "expected a single validation but recieved 0 or multiple validations".into() - )), - } - } -} - -pub struct PgClient { - sql: SqlClient<Postgres>, -} - -impl PgClient { - pub async fn new(pool: sqlx::Pool<Postgres>) -> Arc<dyn Store + Send + Sync + 'static> { - sqlx::migrate!("store/pg/migrations") - .run(&pool) - .await - .expect(ERR_MSG_MIGRATION_FAILED); - - Arc::new(PgClient { - sql: SqlClient { - pool, - sqls_root: PGSQL.to_string(), - }, - }) - } -} - -#[async_trait::async_trait] -impl Store for PgClient { - async fn write_email(&self, email_address: &str) -> Result<(), StoreError> { - self.sql.write_email(email_address).await - } - async fn find_email_validation( - &self, - validation_id: Option<&Uuid>, - code: Option<&str>, - ) -> Result<EmailValidation, StoreError> { - self.sql.find_email_validation(validation_id, code).await - } - async fn write_email_validation(&self, ev: &EmailValidation) -> anyhow::Result<Uuid> { - self.sql.write_email_validation(ev).await - } - async fn find_identity( - &self, - identity_id: Option<&Uuid>, - email: Option<&str>, - ) -> anyhow::Result<Option<Identity>> { - self.sql.find_identity(identity_id, email).await - } - async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError> { - self.sql.find_identity_by_code(code).await - } - async fn write_identity(&self, i: &Identity) -> Result<(), StoreError> { - self.sql.write_identity(i).await - } - async fn read_identity(&self, identity_id: &Uuid) -> Result<Identity, StoreError> { - self.sql.read_identity(identity_id).await - } - async fn write_session(&self, session: &Session) -> Result<(), StoreError> { - self.sql.write_session(session).await - } - async fn read_session(&self, secret: &SessionSecret) -> Result<Session, StoreError> { - self.sql.read_session(secret).await - } - async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError> { - self.sql.write_oauth_provider(provider).await - } - async fn read_oauth_provider( - &self, - provider: &OauthProviderName, - flow: Option<String>, - ) -> Result<OauthProvider, StoreError> { - self.sql.read_oauth_provider(provider, flow).await - } - async fn write_oauth_validation( - &self, - validation: &OauthValidation, - ) -> anyhow::Result<ValidationRequestId> { - self.sql.write_oauth_validation(validation).await - } - async fn read_oauth_validation( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<OauthValidation> { - self.sql.read_oauth_validation(validation_id).await - } - async fn find_validation_type( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<ValidationType> { - self.sql.find_validation_type(validation_id).await - } -} - -pub struct SqliteClient { - sql: SqlClient<Sqlite>, -} - -impl SqliteClient { - pub async fn new(pool: sqlx::Pool<Sqlite>) -> Arc<dyn Store + Send + Sync + 'static> { - sqlx::migrate!("store/sqlite/migrations") - .run(&pool) - .await - .expect(ERR_MSG_MIGRATION_FAILED); - - sqlx::query("pragma foreign_keys = on") - .execute(&pool) - .await - .expect( - "Failed to initialize FK pragma. File a bug at https://www.github.com/secd-lib", - ); - - Arc::new(SqliteClient { - sql: SqlClient { - pool, - sqls_root: SQLITE.to_string(), - }, - }) - } -} - -#[async_trait::async_trait] -impl Store for SqliteClient { - async fn write_email(&self, email_address: &str) -> Result<(), StoreError> { - self.sql.write_email(email_address).await - } - async fn find_email_validation( - &self, - validation_id: Option<&Uuid>, - code: Option<&str>, - ) -> Result<EmailValidation, StoreError> { - self.sql.find_email_validation(validation_id, code).await - } - async fn write_email_validation(&self, ev: &EmailValidation) -> anyhow::Result<Uuid> { - self.sql.write_email_validation(ev).await - } - async fn find_identity( - &self, - identity_id: Option<&Uuid>, - email: Option<&str>, - ) -> anyhow::Result<Option<Identity>> { - self.sql.find_identity(identity_id, email).await - } - async fn find_identity_by_code(&self, code: &str) -> Result<Identity, StoreError> { - self.sql.find_identity_by_code(code).await - } - async fn write_identity(&self, i: &Identity) -> Result<(), StoreError> { - self.sql.write_identity(i).await - } - async fn read_identity(&self, identity_id: &Uuid) -> Result<Identity, StoreError> { - self.sql.read_identity(identity_id).await - } - async fn write_session(&self, session: &Session) -> Result<(), StoreError> { - self.sql.write_session(session).await - } - async fn read_session(&self, secret: &SessionSecret) -> Result<Session, StoreError> { - self.sql.read_session(secret).await - } - async fn write_oauth_provider(&self, provider: &OauthProvider) -> Result<(), StoreError> { - self.sql.write_oauth_provider(provider).await - } - async fn read_oauth_provider( - &self, - provider: &OauthProviderName, - flow: Option<String>, - ) -> Result<OauthProvider, StoreError> { - self.sql.read_oauth_provider(provider, flow).await - } - async fn write_oauth_validation( - &self, - validation: &OauthValidation, - ) -> anyhow::Result<ValidationRequestId> { - self.sql.write_oauth_validation(validation).await - } - async fn read_oauth_validation( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<OauthValidation> { - self.sql.read_oauth_validation(validation_id).await - } - async fn find_validation_type( - &self, - validation_id: &ValidationRequestId, - ) -> anyhow::Result<ValidationType> { - self.sql.find_validation_type(validation_id).await - } -} diff --git a/crates/secd/src/client/store/mod.rs b/crates/secd/src/client/store/mod.rs new file mode 100644 index 0000000..b93fd84 --- /dev/null +++ b/crates/secd/src/client/store/mod.rs @@ -0,0 +1,190 @@ +pub(crate) mod sql_db; + +use email_address::EmailAddress; +use sqlx::{Postgres, Sqlite}; +use std::sync::Arc; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + util, Address, AddressType, AddressValidation, Identity, IdentityId, PhoneNumber, Session, + SessionToken, +}; + +use self::sql_db::SqlClient; + +#[derive(Debug, thiserror::Error, derive_more::Display)] +pub enum StoreError { + SqlClientError(#[from] sqlx::Error), + StoreValueCannotBeParsedInvariant, + IdempotentCheckAlreadyExists, +} + +#[async_trait::async_trait(?Send)] +pub trait Store { + fn get_type(&self) -> StoreType; +} + +pub enum StoreType { + Postgres { c: Arc<SqlClient<Postgres>> }, + Sqlite { c: Arc<SqlClient<Sqlite>> }, +} + +#[async_trait::async_trait(?Send)] +pub(crate) trait Storable<'a> { + type Item; + type Lens; + + async fn write(&self, store: Arc<dyn Store>) -> Result<(), StoreError>; + async fn find( + store: Arc<dyn Store>, + lens: &'a Self::Lens, + ) -> Result<Vec<Self::Item>, StoreError>; +} + +pub(crate) trait Lens {} + +pub(crate) struct AddressLens<'a> { + pub id: Option<&'a Uuid>, + pub t: Option<&'a AddressType>, +} +impl<'a> Lens for AddressLens<'a> {} + +pub(crate) struct AddressValidationLens<'a> { + pub id: Option<&'a Uuid>, +} +impl<'a> Lens for AddressValidationLens<'a> {} + +pub(crate) struct IdentityLens<'a> { + pub id: Option<&'a Uuid>, + pub address_type: Option<&'a AddressType>, + pub validated_address: Option<bool>, + pub session_token_hash: Option<Vec<u8>>, +} +impl<'a> Lens for IdentityLens<'a> {} + +pub(crate) struct SessionLens<'a> { + pub token_hash: Option<&'a Vec<u8>>, + pub identity_id: Option<&'a IdentityId>, +} +impl<'a> Lens for SessionLens<'a> {} + +#[async_trait::async_trait(?Send)] +impl<'a> Storable<'a> for Address { + type Item = Address; + type Lens = AddressLens<'a>; + + async fn write(&self, store: Arc<dyn Store>) -> Result<(), StoreError> { + match store.get_type() { + StoreType::Postgres { c } => c.write_address(self).await?, + StoreType::Sqlite { c } => c.write_address(self).await?, + } + Ok(()) + } + async fn find( + store: Arc<dyn Store>, + lens: &'a Self::Lens, + ) -> Result<Vec<Self::Item>, StoreError> { + let typ = lens.t.map(|at| at.to_string().clone()); + let typ = typ.as_deref(); + + let val = lens.t.and_then(|at| at.get_value()); + let val = val.as_deref(); + + Ok(match store.get_type() { + StoreType::Postgres { c } => c.find_address(lens.id, typ, val).await?, + StoreType::Sqlite { c } => c.find_address(lens.id, typ, val).await?, + }) + } +} + +#[async_trait::async_trait(?Send)] +impl<'a> Storable<'a> for AddressValidation { + type Item = AddressValidation; + type Lens = AddressValidationLens<'a>; + + async fn write(&self, store: Arc<dyn Store>) -> Result<(), StoreError> { + match store.get_type() { + StoreType::Sqlite { c } => c.write_address_validation(self).await?, + StoreType::Postgres { c } => c.write_address_validation(self).await?, + } + Ok(()) + } + async fn find( + store: Arc<dyn Store>, + lens: &'a Self::Lens, + ) -> Result<Vec<Self::Item>, StoreError> { + Ok(match store.get_type() { + StoreType::Postgres { c } => c.find_address_validation(lens.id).await?, + StoreType::Sqlite { c } => c.find_address_validation(lens.id).await?, + }) + } +} + +#[async_trait::async_trait(?Send)] +impl<'a> Storable<'a> for Identity { + type Item = Identity; + type Lens = IdentityLens<'a>; + + async fn write(&self, store: Arc<dyn Store>) -> Result<(), StoreError> { + match store.get_type() { + StoreType::Postgres { c } => c.write_identity(self).await?, + StoreType::Sqlite { c } => c.write_identity(self).await?, + } + Ok(()) + } + async fn find( + store: Arc<dyn Store>, + lens: &'a Self::Lens, + ) -> Result<Vec<Self::Item>, StoreError> { + let val = lens.address_type.and_then(|at| at.get_value()); + let val = val.as_deref(); + + Ok(match store.get_type() { + StoreType::Postgres { c } => { + c.find_identity( + lens.id, + val, + lens.validated_address, + &lens.session_token_hash, + ) + .await? + } + StoreType::Sqlite { c } => { + c.find_identity( + lens.id, + val, + lens.validated_address, + &lens.session_token_hash, + ) + .await? + } + }) + } +} + +#[async_trait::async_trait(?Send)] +impl<'a> Storable<'a> for Session { + type Item = Session; + type Lens = SessionLens<'a>; + + async fn write(&self, store: Arc<dyn Store>) -> Result<(), StoreError> { + let token_hash = util::hash(&self.token); + match store.get_type() { + StoreType::Postgres { c } => c.write_session(self, &token_hash).await?, + StoreType::Sqlite { c } => c.write_session(self, &token_hash).await?, + } + Ok(()) + } + async fn find( + store: Arc<dyn Store>, + lens: &'a Self::Lens, + ) -> Result<Vec<Self::Item>, StoreError> { + let token = lens.token_hash.map(|t| t.clone()).unwrap_or(vec![]); + + Ok(match store.get_type() { + StoreType::Postgres { c } => c.find_session(token, lens.identity_id).await?, + StoreType::Sqlite { c } => c.find_session(token, lens.identity_id).await?, + }) + } +} diff --git a/crates/secd/src/client/store/sql_db.rs b/crates/secd/src/client/store/sql_db.rs new file mode 100644 index 0000000..6d84301 --- /dev/null +++ b/crates/secd/src/client/store/sql_db.rs @@ -0,0 +1,526 @@ +use std::{str::FromStr, sync::Arc}; + +use email_address::EmailAddress; +use serde_json::value::RawValue; +use sqlx::{ + database::HasArguments, types::Json, ColumnIndex, Database, Decode, Encode, Executor, + IntoArguments, Pool, Transaction, Type, +}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + Address, AddressType, AddressValidation, AddressValidationMethod, Identity, Session, + SessionToken, +}; + +use lazy_static::lazy_static; +use sqlx::{Postgres, Sqlite}; +use std::collections::HashMap; + +use super::{ + AddressLens, AddressValidationLens, IdentityLens, SessionLens, Storable, Store, StoreError, + StoreType, +}; + +const SQLITE: &str = "sqlite"; +const PGSQL: &str = "pgsql"; + +const WRITE_ADDRESS: &str = "write_address"; +const FIND_ADDRESS: &str = "find_address"; +const WRITE_ADDRESS_VALIDATION: &str = "write_address_validation"; +const FIND_ADDRESS_VALIDATION: &str = "find_address_validation"; +const WRITE_IDENTITY: &str = "write_identity"; +const FIND_IDENTITY: &str = "find_identity"; +const WRITE_SESSION: &str = "write_session"; +const FIND_SESSION: &str = "find_session"; + +const ERR_MSG_MIGRATION_FAILED: &str = "Failed to apply secd migrations to a sql db. File a bug at https://www.github.com/branchcontrol/secdiam"; + +lazy_static! { + static ref SQLS: HashMap<&'static str, HashMap<&'static str, &'static str>> = { + let sqlite_sqls: HashMap<&'static str, &'static str> = [ + ( + WRITE_ADDRESS, + include_str!("../../../store/sqlite/sql/write_address.sql"), + ), + ( + FIND_ADDRESS, + include_str!("../../../store/sqlite/sql/find_address.sql"), + ), + ( + WRITE_ADDRESS_VALIDATION, + include_str!("../../../store/sqlite/sql/write_address_validation.sql"), + ), + ( + FIND_ADDRESS_VALIDATION, + include_str!("../../../store/sqlite/sql/find_address_validation.sql"), + ), + ( + WRITE_IDENTITY, + include_str!("../../../store/sqlite/sql/write_identity.sql"), + ), + ( + FIND_IDENTITY, + include_str!("../../../store/sqlite/sql/find_identity.sql"), + ), + ( + WRITE_SESSION, + include_str!("../../../store/sqlite/sql/write_session.sql"), + ), + ( + FIND_SESSION, + include_str!("../../../store/sqlite/sql/find_session.sql"), + ), + ] + .iter() + .cloned() + .collect(); + + let pg_sqls: HashMap<&'static str, &'static str> = [ + ( + WRITE_ADDRESS, + include_str!("../../../store/pg/sql/write_address.sql"), + ), + ( + FIND_ADDRESS, + include_str!("../../../store/pg/sql/find_address.sql"), + ), + ( + WRITE_ADDRESS_VALIDATION, + include_str!("../../../store/pg/sql/write_address_validation.sql"), + ), + ( + FIND_ADDRESS_VALIDATION, + include_str!("../../../store/pg/sql/find_address_validation.sql"), + ), + ( + WRITE_IDENTITY, + include_str!("../../../store/pg/sql/write_identity.sql"), + ), + ( + FIND_IDENTITY, + include_str!("../../../store/pg/sql/find_identity.sql"), + ), + ( + WRITE_SESSION, + include_str!("../../../store/pg/sql/write_session.sql"), + ), + ( + FIND_SESSION, + include_str!("../../../store/pg/sql/find_session.sql"), + ), + ] + .iter() + .cloned() + .collect(); + + let sqls: HashMap<&'static str, HashMap<&'static str, &'static str>> = + [(SQLITE, sqlite_sqls), (PGSQL, pg_sqls)] + .iter() + .cloned() + .collect(); + sqls + }; +} + +pub trait SqlxResultExt<T> { + fn extend_err(self) -> Result<T, StoreError>; +} + +impl<T> SqlxResultExt<T> for Result<T, sqlx::Error> { + fn extend_err(self) -> Result<T, StoreError> { + if let Err(sqlx::Error::Database(dbe)) = &self { + if dbe.code() == Some("23505".into()) { + return Err(StoreError::IdempotentCheckAlreadyExists); + } + } + self.map_err(|e| StoreError::SqlClientError(e)) + } +} + +pub struct SqlClient<D> +where + D: sqlx::Database, +{ + pool: sqlx::Pool<D>, + sqls_root: String, +} + +pub struct PgClient { + sql: Arc<SqlClient<Postgres>>, +} +impl Store for PgClient { + fn get_type(&self) -> StoreType { + StoreType::Postgres { + c: self.sql.clone(), + } + } +} + +impl PgClient { + pub async fn new(pool: sqlx::Pool<Postgres>) -> Arc<dyn Store + Send + Sync + 'static> { + sqlx::migrate!("store/pg/migrations") + .run(&pool) + .await + .expect(ERR_MSG_MIGRATION_FAILED); + + Arc::new(PgClient { + sql: Arc::new(SqlClient { + pool, + sqls_root: PGSQL.to_string(), + }), + }) + } +} + +pub struct SqliteClient { + sql: Arc<SqlClient<Sqlite>>, +} +impl Store for SqliteClient { + fn get_type(&self) -> StoreType { + StoreType::Sqlite { + c: self.sql.clone(), + } + } +} + +impl SqliteClient { + pub async fn new(pool: sqlx::Pool<Sqlite>) -> Arc<dyn Store + Send + Sync + 'static> { + sqlx::migrate!("store/sqlite/migrations") + .run(&pool) + .await + .expect(ERR_MSG_MIGRATION_FAILED); + + sqlx::query("pragma foreign_keys = on") + .execute(&pool) + .await + .expect( + "Failed to initialize FK pragma. File a bug at https://www.github.com/secd-lib", + ); + + Arc::new(SqliteClient { + sql: Arc::new(SqlClient { + pool, + sqls_root: SQLITE.to_string(), + }), + }) + } +} + +impl<D> SqlClient<D> +where + D: sqlx::Database, + for<'c> &'c Pool<D>: Executor<'c, Database = D>, + for<'c> &'c mut Transaction<'c, D>: Executor<'c, Database = D>, + for<'c> <D as HasArguments<'c>>::Arguments: IntoArguments<'c, D>, + for<'c> bool: Decode<'c, D> + Type<D>, + for<'c> bool: Encode<'c, D> + Type<D>, + for<'c> Option<bool>: Decode<'c, D> + Type<D>, + for<'c> Option<bool>: Encode<'c, D> + Type<D>, + for<'c> i64: Decode<'c, D> + Type<D>, + for<'c> i64: Encode<'c, D> + Type<D>, + for<'c> i32: Decode<'c, D> + Type<D>, + for<'c> i32: Encode<'c, D> + Type<D>, + for<'c> OffsetDateTime: Decode<'c, D> + Type<D>, + for<'c> OffsetDateTime: Encode<'c, D> + Type<D>, + for<'c> &'c str: ColumnIndex<<D as Database>::Row>, + for<'c> &'c str: Decode<'c, D> + Type<D>, + for<'c> &'c str: Encode<'c, D> + Type<D>, + for<'c> Option<&'c str>: Decode<'c, D> + Type<D>, + for<'c> Option<&'c str>: Encode<'c, D> + Type<D>, + for<'c> String: Decode<'c, D> + Type<D>, + for<'c> String: Encode<'c, D> + Type<D>, + for<'c> Option<String>: Decode<'c, D> + Type<D>, + for<'c> Option<String>: Encode<'c, D> + Type<D>, + for<'c> usize: ColumnIndex<<D as Database>::Row>, + for<'c> Uuid: Decode<'c, D> + Type<D>, + for<'c> Uuid: Encode<'c, D> + Type<D>, + for<'c> &'c [u8]: Encode<'c, D> + Type<D>, + for<'c> Option<&'c Uuid>: Encode<'c, D> + Type<D>, + for<'c> Vec<u8>: Encode<'c, D> + Type<D>, + for<'c> Vec<u8>: Decode<'c, D> + Type<D>, + for<'c> Option<Vec<u8>>: Encode<'c, D> + Type<D>, + for<'c> Option<Vec<u8>>: Decode<'c, D> + Type<D>, + for<'c> Option<OffsetDateTime>: Decode<'c, D> + Type<D>, + for<'c> Option<OffsetDateTime>: Encode<'c, D> + Type<D>, +{ + pub async fn write_address(&self, a: &Address) -> Result<(), StoreError> { + let sqls = get_sqls(&self.sqls_root, WRITE_ADDRESS); + sqlx::query(&sqls[0]) + .bind(a.id) + .bind(a.t.to_string()) + .bind(match &a.t { + AddressType::Email { email_address } => { + email_address.as_ref().map(ToString::to_string) + } + AddressType::Sms { phone_number } => phone_number.clone(), + }) + .bind(a.created_at) + .execute(&self.pool) + .await + .extend_err()?; + + Ok(()) + } + + pub async fn find_address( + &self, + id: Option<&Uuid>, + typ: Option<&str>, + val: Option<&str>, + ) -> Result<Vec<Address>, StoreError> { + let sqls = get_sqls(&self.sqls_root, FIND_ADDRESS); + let res = sqlx::query_as::<_, (Uuid, String, String, OffsetDateTime)>(&sqls[0]) + .bind(id) + .bind(typ) + .bind(val) + .fetch_all(&self.pool) + .await + .extend_err()?; + + let mut addresses = vec![]; + for (id, typ, val, created_at) in res.into_iter() { + let t = match AddressType::from_str(&typ) + .map_err(|_| StoreError::StoreValueCannotBeParsedInvariant)? + { + AddressType::Email { .. } => AddressType::Email { + email_address: Some( + EmailAddress::from_str(&val) + .map_err(|_| StoreError::StoreValueCannotBeParsedInvariant)?, + ), + }, + AddressType::Sms { .. } => AddressType::Sms { + phone_number: Some(val.clone()), + }, + }; + + addresses.push(Address { id, t, created_at }); + } + + Ok(addresses) + } + + pub async fn write_address_validation(&self, v: &AddressValidation) -> Result<(), StoreError> { + let sqls = get_sqls(&self.sqls_root, WRITE_ADDRESS_VALIDATION); + sqlx::query(&sqls[0]) + .bind(v.id) + .bind(v.identity_id.as_ref()) + .bind(v.address.id) + .bind(v.method.to_string()) + .bind(v.hashed_token.clone()) + .bind(v.hashed_code.clone()) + .bind(v.attempts) + .bind(v.created_at) + .bind(v.expires_at) + .bind(v.revoked_at) + .bind(v.validated_at) + .execute(&self.pool) + .await + .extend_err()?; + + Ok(()) + } + + pub async fn find_address_validation( + &self, + id: Option<&Uuid>, + ) -> Result<Vec<AddressValidation>, StoreError> { + let sqls = get_sqls(&self.sqls_root, FIND_ADDRESS_VALIDATION); + let rs = sqlx::query_as::< + _, + ( + Uuid, + Option<Uuid>, + Uuid, + String, + String, + OffsetDateTime, + String, + Vec<u8>, + Vec<u8>, + i32, + OffsetDateTime, + OffsetDateTime, + Option<OffsetDateTime>, + Option<OffsetDateTime>, + ), + >(&sqls[0]) + .bind(id) + .fetch_all(&self.pool) + .await + .extend_err()?; + + let mut res = vec![]; + for ( + id, + identity_id, + address_id, + address_typ, + address_val, + address_created_at, + method, + hashed_token, + hashed_code, + attempts, + created_at, + expires_at, + revoked_at, + validated_at, + ) in rs.into_iter() + { + let t = match AddressType::from_str(&address_typ) + .map_err(|_| StoreError::StoreValueCannotBeParsedInvariant)? + { + AddressType::Email { .. } => AddressType::Email { + email_address: Some( + EmailAddress::from_str(&address_val) + .map_err(|_| StoreError::StoreValueCannotBeParsedInvariant)?, + ), + }, + AddressType::Sms { .. } => AddressType::Sms { + phone_number: Some(address_val.clone()), + }, + }; + + res.push(AddressValidation { + id, + identity_id, + address: Address { + id: address_id, + t, + created_at: address_created_at, + }, + method: AddressValidationMethod::from_str(&method) + .map_err(|_| StoreError::StoreValueCannotBeParsedInvariant)?, + created_at, + expires_at, + revoked_at, + validated_at, + attempts, + hashed_token, + hashed_code, + }); + } + + Ok(res) + } + + pub async fn write_identity(&self, i: &Identity) -> Result<(), StoreError> { + let sqls = get_sqls(&self.sqls_root, WRITE_IDENTITY); + sqlx::query(&sqls[0]) + .bind(i.id) + // TODO: validate this is actually Json somewhere way up the chain (when being deserialized) + .bind(i.metadata.clone().unwrap_or("{}".into())) + .bind(i.created_at) + .bind(OffsetDateTime::now_utc()) + .bind(i.deleted_at) + .execute(&self.pool) + .await + .extend_err()?; + + Ok(()) + } + + pub async fn find_identity( + &self, + id: Option<&Uuid>, + address_value: Option<&str>, + address_is_validated: Option<bool>, + session_token_hash: &Option<Vec<u8>>, + ) -> Result<Vec<Identity>, StoreError> { + let sqls = get_sqls(&self.sqls_root, FIND_IDENTITY); + println!("{:?}", id); + println!("{:?}", address_value); + println!("{:?}", address_is_validated); + println!("{:?}", session_token_hash); + let rs = sqlx::query_as::< + _, + ( + Uuid, + Option<String>, + OffsetDateTime, + OffsetDateTime, + Option<OffsetDateTime>, + ), + >(&sqls[0]) + .bind(id) + .bind(address_value) + .bind(address_is_validated) + .bind(session_token_hash) + .fetch_all(&self.pool) + .await + .extend_err()?; + + let mut res = vec![]; + for (id, metadata, created_at, updated_at, deleted_at) in rs.into_iter() { + res.push(Identity { + id, + address_validations: vec![], + credentials: vec![], + rules: vec![], + metadata, + created_at, + deleted_at, + }) + } + + Ok(res) + } + + pub async fn write_session(&self, s: &Session, token_hash: &[u8]) -> Result<(), StoreError> { + let sqls = get_sqls(&self.sqls_root, WRITE_SESSION); + sqlx::query(&sqls[0]) + .bind(s.identity_id) + .bind(token_hash) + .bind(s.created_at) + .bind(s.expired_at) + .bind(s.revoked_at) + .execute(&self.pool) + .await + .extend_err()?; + + Ok(()) + } + + pub async fn find_session( + &self, + token: Vec<u8>, + identity_id: Option<&Uuid>, + ) -> Result<Vec<Session>, StoreError> { + let sqls = get_sqls(&self.sqls_root, FIND_SESSION); + let rs = + sqlx::query_as::<_, (Uuid, OffsetDateTime, OffsetDateTime, Option<OffsetDateTime>)>( + &sqls[0], + ) + .bind(token) + .bind(identity_id) + .bind(OffsetDateTime::now_utc()) + .bind(OffsetDateTime::now_utc()) + .fetch_all(&self.pool) + .await + .extend_err()?; + + let mut res = vec![]; + for (identity_id, created_at, expired_at, revoked_at) in rs.into_iter() { + res.push(Session { + identity_id, + token: vec![], + created_at, + expired_at, + revoked_at, + }); + } + Ok(res) + } +} + +fn get_sqls(root: &str, file: &str) -> Vec<String> { + SQLS.get(root) + .unwrap() + .get(file) + .unwrap() + .split("--") + .map(|p| p.to_string()) + .collect() +} diff --git a/crates/secd/src/client/types.rs b/crates/secd/src/client/types.rs deleted file mode 100644 index bacade4..0000000 --- a/crates/secd/src/client/types.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) struct Email { - address: String, -} diff --git a/crates/secd/src/command/admin.rs b/crates/secd/src/command/admin.rs deleted file mode 100644 index b04dbef..0000000 --- a/crates/secd/src/command/admin.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::str::FromStr; - -use time::OffsetDateTime; -use url::Url; - -use crate::{OauthProviderName, Secd, SecdError}; - -impl OauthProviderName { - fn base_url(&self) -> Url { - match self { - OauthProviderName::Google => { - Url::from_str("https://accounts.google.com/o/oauth2/v2/auth").unwrap() - } - OauthProviderName::Microsoft => { - Url::from_str("https://login.microsoftonline.com/common/oauth2/v2.0/authorize") - .unwrap() - } - _ => unimplemented!(), - } - } - - fn default_scope(&self) -> String { - match self { - OauthProviderName::Google => "openid%20email".into(), - OauthProviderName::Microsoft => "openid%20email".into(), - _ => unimplemented!(), - } - } -} - -impl Secd { - pub async fn create_oauth_provider( - &self, - provider: &OauthProviderName, - client_id: String, - client_secret: String, - redirect_url: Url, - ) -> Result<(), SecdError> { - self.store - .write_oauth_provider(&crate::OauthProvider { - name: provider.clone(), - flow: Some("default".into()), - base_url: provider.base_url(), - response: crate::OauthResponseType::Code, - default_scope: provider.default_scope(), - client_id, - client_secret, - redirect_url, - created_at: OffsetDateTime::now_utc(), - deleted_at: None, - }) - .await - .map_err(|_| SecdError::Todo)?; - - Ok(()) - } -} diff --git a/crates/secd/src/command/authn.rs b/crates/secd/src/command/authn.rs index 9c2babe..5590e8c 100644 --- a/crates/secd/src/command/authn.rs +++ b/crates/secd/src/command/authn.rs @@ -1,225 +1,281 @@ -use email_address::EmailAddress; -use log::debug; -use rand::distributions::{Alphanumeric, DistString}; -use time::Duration; -use time::OffsetDateTime; -use uuid::Uuid; +use std::str::FromStr; -use crate::util::{build_oauth_auth_url, get_oauth_access_token}; -use crate::OauthRedirectAuthUrl; -use crate::Validation; -use crate::ValidationType; -use crate::INTERNAL_ERR_MSG; use crate::{ - client, util, EmailValidation, Identity, OauthProviderName, Secd, SecdError, Session, - ValidationRequestId, ValidationSecretCode, EMAIL_VALIDATION_DURATION, SESSION_DURATION, - SESSION_SIZE_BYTES, VALIDATION_CODE_SIZE, + client::{ + email::{EmailValidationMessage, Sendable}, + store::{ + AddressLens, AddressValidationLens, IdentityLens, SessionLens, Storable, StoreError, + }, + }, + util, Address, AddressType, AddressValidation, AddressValidationId, AddressValidationMethod, + Credential, CredentialType, Identity, IdentityId, PhoneNumber, Secd, SecdError, Session, + SessionToken, ADDRESSS_VALIDATION_CODE_SIZE, ADDRESS_VALIDATION_ALLOWS_ATTEMPTS, + ADDRESS_VALIDATION_IDENTITY_SURJECTION, EMAIL_VALIDATION_DURATION, }; +use email_address::EmailAddress; +use log::warn; +use rand::Rng; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; impl Secd { - /// create_validation_request_oauth - /// - /// Generate a request to validate with the specified oauth provider.[ - // TODO: How to handle different oauth "flows"? e.g. web app vs desktop vs mobile... - pub async fn create_validation_request_oauth( + pub async fn validate_email( &self, - provider: &OauthProviderName, - scope: Option<String>, - ) -> Result<OauthRedirectAuthUrl, SecdError> { - if scope.is_some() { - return Err(SecdError::NotImplemented( - "Only default scopes are currently supported.".into(), - )); + email_address: &str, + identity_id: Option<Uuid>, + ) -> Result<AddressValidation, SecdError> { + let email_address = EmailAddress::from_str(email_address)?; + // record address (idempotent operation) + let mut address = Address { + id: Uuid::new_v4(), + t: AddressType::Email { + email_address: Some(email_address.clone()), + }, + created_at: OffsetDateTime::now_utc(), + }; + + if let Err(StoreError::IdempotentCheckAlreadyExists) = + address.write(self.store.clone()).await + { + address = Address::find( + self.store.clone(), + &AddressLens { + id: None, + t: Some(&AddressType::Email { + email_address: Some(email_address.clone()), + }), + }, + ) + .await? + .into_iter() + .next() + .ok_or(SecdError::AddressValidationFailed)?; } - let p = self - .store - .read_oauth_provider(provider, None) - .await - .map_err(|_| SecdError::InternalError(INTERNAL_ERR_MSG.to_string()))?; + let secret = hex::encode(rand::thread_rng().gen::<[u8; 32]>()); + let code: String = vec![0; ADDRESSS_VALIDATION_CODE_SIZE as usize] + .into_iter() + .map(|_| char::from_digit(rand::thread_rng().gen_range(0..=9), 10).unwrap()) + .collect(); - let req_id = self - .store - .write_oauth_validation(&crate::OauthValidation { - id: Some(Uuid::new_v4()), - identity_id: None, - oauth_provider: p.clone(), - access_token: None, - raw_response: None, - created_at: OffsetDateTime::now_utc(), - validated_at: None, - revoked_at: None, - deleted_at: None, - }) - .await - .map_err(|e| util::to_secd_err(e, SecdError::OauthValidationRequestError))?; + let mut validation = AddressValidation { + id: Uuid::new_v4(), + identity_id, + address, + method: AddressValidationMethod::Email, + created_at: OffsetDateTime::now_utc(), + expires_at: OffsetDateTime::now_utc() + .checked_add(Duration::new(EMAIL_VALIDATION_DURATION, 0)) + .ok_or(SecdError::Todo)?, + revoked_at: None, + validated_at: None, + attempts: 0, + hashed_token: util::hash(&secret.as_bytes()), + hashed_code: util::hash(&code.as_bytes()), + }; + + validation.write(self.store.clone()).await?; - build_oauth_auth_url(&p, req_id) + let msg =EmailValidationMessage { + recipient: email_address.clone(), + subject: "Confirm Your Email".into(), + body: format!("This is an email validation message. Click this link [{:?}?s={}] or use the code [{}]", validation.id, secret, code), + }; + + match msg.send().await { + Ok(_) => { /* TODO: Write down the message*/ } + Err(e) => { + validation.revoked_at = Some(OffsetDateTime::now_utc()); + validation.write(self.store.clone()).await?; + return Err(SecdError::EmailMessengerError(e)); + } + } + + Ok(validation) } - /// create_validation_request_email - /// - /// Generate a request to validate the provided email. - pub async fn create_validation_request_email( + pub async fn validate_sms( &self, - email: &str, - ) -> Result<ValidationRequestId, SecdError> { - let now = OffsetDateTime::now_utc(); + phone_number: &PhoneNumber, + ) -> Result<AddressValidation, SecdError> { + todo!() + } + pub async fn complete_address_validation( + &self, + validation_id: &AddressValidationId, + plaintext_token: Option<String>, + plaintext_code: Option<String>, + ) -> Result<Session, SecdError> { + let mut validation = AddressValidation::find( + self.store.clone(), + &AddressValidationLens { + id: Some(validation_id), + }, + ) + .await? + .into_iter() + .next() + .ok_or(SecdError::AddressValidationFailed)?; - let email = if EmailAddress::is_valid(email) { - email - } else { - return Err(SecdError::InvalidEmailAddress); - }; + if validation.validated_at.is_some() { + return Err(SecdError::AddressValidationExpiredOrConsumed); + } - let mut ev = EmailValidation { - id: None, - identity_id: None, - email_address: email.to_string(), - code: Some( - Alphanumeric - .sample_string(&mut rand::thread_rng(), VALIDATION_CODE_SIZE) - .to_lowercase(), - ), - is_oauth_derived: false, - created_at: now, - expired_at: now - .checked_add(Duration::new(EMAIL_VALIDATION_DURATION, 0)) - .ok_or(SecdError::EmailValidationExpiryOverflow)?, - validated_at: None, - revoked_at: None, - deleted_at: None, - }; + validation.attempts += 1; + if validation.attempts > ADDRESS_VALIDATION_ALLOWS_ATTEMPTS as i32 { + warn!( + "validation failed: Too many validation attempts were tried for validation {:?}", + validation.id + ); + validation.write(self.store.clone()).await?; + return Err(SecdError::AddressValidationExpiredOrConsumed); + } - let (req_id, mail_type) = match self - .store - .find_identity(None, Some(email)) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Todo))? - { - Some(identity) => { - let req_id = { - ev.identity_id = Some(identity.id); - self.store - .write_email_validation(&ev) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Todo))? - }; - (req_id, client::EmailType::Login) + let hashed_token = plaintext_token.map(|s| util::hash(s.as_bytes())); + let hashed_code = plaintext_code.map(|c| util::hash(c.as_bytes())); + + let mut warn_msg = None; + match (hashed_token, hashed_code) { + (None, None) => { + warn_msg = Some("neither token nor hash was provided during the address validation session exchange"); } - None => { - self.store - .write_email(email) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Todo))?; - - let req_id = { - self.store - .write_email_validation(&ev) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Todo))? - }; - - (req_id, client::EmailType::Signup) + (Some(t), None) => { + if validation.hashed_token != t { + warn_msg = + Some("the provided token does not match the address validation token"); + } + } + (None, Some(c)) => { + if validation.hashed_code != c { + warn_msg = Some("the provided code does not match the address validation code"); + } + } + (Some(t), Some(c)) => { + if validation.hashed_token != t || validation.hashed_code != c { + warn_msg = Some("the provided token and code must both match the address validation token and code"); + } } }; - self.email_messenger - .send_email(email, &req_id.to_string(), &ev.code.unwrap(), mail_type) - .await?; + if let Some(msg) = warn_msg { + warn!("validation failed: {}", msg); + validation.write(self.store.clone()).await?; + return Err(SecdError::AddressValidationSessionExchangeFailed); + } + + let identity = Identity::find( + self.store.clone(), + &IdentityLens { + id: None, + address_type: Some(&validation.address.t), + validated_address: Some(true), + session_token_hash: None, + }, + ) + .await?; + + if !ADDRESS_VALIDATION_IDENTITY_SURJECTION && identity.len() > 1 { + warn!("validation failed: identity validation surjection disallowed"); + validation.write(self.store.clone()).await?; + return Err(SecdError::TooManyIdentities); + } + + let mut identity = identity.into_iter().next(); + if identity.is_none() { + let i = Identity { + id: Uuid::new_v4(), + address_validations: vec![], + credentials: vec![], + rules: vec![], + metadata: None, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + }; + i.write(self.store.clone()).await?; + identity = Some(i); + } - Ok(req_id) + assert!(identity.is_some()); + + // If the validation was attached to another identity, unless surjection is allowed, it cannot be recorded. + if !ADDRESS_VALIDATION_IDENTITY_SURJECTION + && validation.identity_id.is_some() + && identity.as_ref().map(|i| i.id) != validation.identity_id + { + warn!("validation failed: identity validation surjection is disallowed, but found existing identity for another account"); + validation.write(self.store.clone()).await?; + return Err(SecdError::TooManyIdentities); + } + + validation.identity_id = identity.map(|i| i.id); + validation.validated_at = Some(OffsetDateTime::now_utc()); + validation.write(self.store.clone()).await?; + + let session = Session::new(validation.identity_id.expect("unreachable d3ded289-72eb-4a42-a37d-f5c9c697cc61 [assert(identity.is_some()) prevents this]"))?; + session.write(self.store.clone()).await?; + + Ok(session) } - /// exchange_secret_for_session - /// - /// Exchanges a secret, which consists of a validation_request_id and secret_code - /// for a session which allows authentication on behalf of the associated identity. - /// - /// Session secrets should be used to return authorization for the associated identity. - pub async fn exchange_code_for_session( + pub async fn create_credential( &self, - validation_request_id: ValidationRequestId, - code: ValidationSecretCode, - ) -> Result<Session, SecdError> { - let mut v: Box<dyn Validation> = match self - .store - .find_validation_type(&validation_request_id) - .await - .map_err(|e| util::to_secd_err(e, SecdError::Todo))? - { - ValidationType::Email => Box::new( - self.store - .find_email_validation(Some(&validation_request_id), Some(&code)) - .await - .map_err(|e| { - util::log_err(e.into(), SecdError::EmailValidationExpiryOverflow) - })?, - ), - ValidationType::Oauth => Box::new({ - let mut t = self - .store - .read_oauth_validation(&validation_request_id) - .await - .map_err(|e| util::to_secd_err(e, SecdError::Todo))?; - - let access_token = get_oauth_access_token(&t, &code) - .await - .map_err(|_| SecdError::Todo)?; - - t.access_token = Some(access_token); - t - }), - }; + t: CredentialType, + key: String, + value: Option<String>, + ) -> Result<Credential, SecdError> { + todo!() + } - if v.expired() || v.is_validated() { - return Err(SecdError::InvalidCode); - }; + pub async fn validate_credential( + &self, + t: CredentialType, + key: String, + value: Option<String>, + ) -> Result<Session, SecdError> { + todo!() + } - let mut identity = Identity { - id: Uuid::new_v4(), - data: None, - created_at: OffsetDateTime::now_utc(), - deleted_at: None, - }; + pub async fn get_session(&self, t: &SessionToken) -> Result<Session, SecdError> { + let token = hex::decode(t)?; + let mut session = Session::find( + self.store.clone(), + &SessionLens { + token_hash: Some(&util::hash(&token)), + identity_id: None, + }, + ) + .await?; + assert!(session.len() <= 1, "get session failed: multiple sessions found for a single token. This is very _very_ bad."); - match v - .find_associated_identities(self.store.clone()) - .await - .map_err(|e| util::to_secd_err(e, SecdError::IdentityIdShouldExistInvariant))? - { - Some(i) => identity.id = i.id, - _ => self.store.write_identity(&identity).await.map_err(|_| { - SecdError::InternalError("failed to write identity during session exchange".into()) - })?, - }; + if session.is_empty() { + return Err(SecdError::InvalidSession); + } else { + let mut session = session.swap_remove(0); + session.token = token; + Ok(session) + } + } - v.validate(&identity, self.store.clone()) - .await - .map_err(|e| { - util::to_secd_err( - e, - SecdError::InternalError( - "failed to update validation during session exchange".into(), - ), - ) - })?; - - // TODO: clear previous sessions if they fit the criteria - let now = OffsetDateTime::now_utc(); - let s = Session { - identity_id: identity.id, - secret: Some(Alphanumeric.sample_string(&mut rand::thread_rng(), SESSION_SIZE_BYTES)), - created_at: now, - expired_at: now - .checked_add(Duration::new(SESSION_DURATION, 0)) - .ok_or(SecdError::SessionExpiryOverflow)?, - revoked_at: None, - }; + pub async fn get_identity(&self, i: &SessionToken) -> Result<Identity, SecdError> { + let token_hash = util::hash(&hex::decode(i)?); + let mut i = Identity::find( + self.store.clone(), + &IdentityLens { + id: None, + address_type: None, + validated_address: None, + session_token_hash: Some(token_hash), + }, + ) + .await?; - self.store - .write_session(&s) - .await - .map_err(|e| util::log_err(e.into(), SecdError::Todo))?; + assert!( + i.len() <= 1, + "The provided id refers to more than one identity. This is very _very_ bad." + ); - Ok(s) + if i.is_empty() { + return Err(SecdError::IdentityNotFound); + } else { + Ok(i.swap_remove(0)) + } } } diff --git a/crates/secd/src/command/mod.rs b/crates/secd/src/command/mod.rs index cd0d8c3..c14cf6c 100644 --- a/crates/secd/src/command/mod.rs +++ b/crates/secd/src/command/mod.rs @@ -1,42 +1,54 @@ -pub mod admin; pub mod authn; -use crate::client::{ - email, - sqldb::{PgClient, SqliteClient}, +use super::{AuthEmailMessenger, AuthStore, Secd, SecdError}; +use crate::{ + client::{ + email, + store::sql_db::{PgClient, SqliteClient}, + }, + ENV_AUTH_STORE_CONN_STRING, ENV_EMAIL_MESSENGER, ENV_EMAIL_MESSENGER_CLIENT_ID, + ENV_EMAIL_MESSENGER_CLIENT_SECRET, }; -use crate::{AuthEmail, AuthStore, Secd, SecdError}; -use log::error; -use std::sync::Arc; +use log::{error, info}; +use std::{env::var, str::FromStr, sync::Arc}; impl Secd { /// init /// /// Initialize SecD with the specified configuration, established the necessary /// constraints, persistance stores, and options. - pub async fn init( - auth_store: AuthStore, - conn_string: Option<&str>, - email_messenger: AuthEmail, - email_template_login: Option<String>, - email_template_signup: Option<String>, - ) -> Result<Self, SecdError> { + pub async fn init() -> Result<Self, SecdError> { + let auth_store = AuthStore::from(var(ENV_AUTH_STORE_CONN_STRING).ok()); + let email_messenger = AuthEmailMessenger::from_str( + &var(ENV_EMAIL_MESSENGER).unwrap_or(AuthEmailMessenger::Local.to_string()), + ) + .expect("unreachable f4ad0f48-0812-427f-b477-0f9c67bb69c5"); + let email_messenger_client_id = var(ENV_EMAIL_MESSENGER_CLIENT_ID).ok(); + let email_messenger_client_secret = var(ENV_EMAIL_MESSENGER_CLIENT_SECRET).ok(); + + info!("starting client with auth_store: {:?}", auth_store); + info!("starting client with email_messenger: {:?}", auth_store); + let store = match auth_store { - AuthStore::Sqlite => { + AuthStore::Sqlite { conn } => { SqliteClient::new( sqlx::sqlite::SqlitePoolOptions::new() - .connect(conn_string.unwrap_or("sqlite::memory:".into())) + .connect(&conn) .await - .map_err(|e| SecdError::InitializationFailure(e))?, + .map_err(|e| { + SecdError::StoreInitFailure(format!("failed to init sqlite: {}", e)) + })?, ) .await } - AuthStore::Postgres => { + AuthStore::Postgres { conn } => { PgClient::new( sqlx::postgres::PgPoolOptions::new() - .connect(conn_string.expect("No postgres connection string provided.")) + .connect(&conn) .await - .map_err(|e| SecdError::InitializationFailure(e))?, + .map_err(|e| { + SecdError::StoreInitFailure(format!("failed to init sqlite: {}", e)) + })?, ) .await } @@ -50,11 +62,7 @@ impl Secd { }; let email_sender = match email_messenger { - // TODO: initialize email and SMS templates with secd - AuthEmail::LocalStub => email::LocalEmailStubber { - email_template_login, - email_template_signup, - }, + AuthEmailMessenger::Local => email::LocalMailer {}, _ => unimplemented!(), }; diff --git a/crates/secd/src/lib.rs b/crates/secd/src/lib.rs index 17186c8..c84f7cf 100644 --- a/crates/secd/src/lib.rs +++ b/crates/secd/src/lib.rs @@ -2,382 +2,185 @@ mod client; mod command; mod util; -use std::sync::Arc; - -use clap::ValueEnum; -use client::{EmailMessenger, EmailMessengerError, Store}; -use derive_more::Display; +use client::{ + email::{EmailMessenger, EmailMessengerError}, + store::{Store, StoreError}, +}; use email_address::EmailAddress; use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use strum_macros::{EnumString, EnumVariantNames}; +use serde_with::{serde_as, DisplayFromStr}; +use std::sync::Arc; +use strum_macros::{Display, EnumString, EnumVariantNames}; use time::OffsetDateTime; use url::Url; -use util::get_oauth_identity_data; use uuid::Uuid; +pub const ENV_AUTH_STORE_CONN_STRING: &str = "SECD_AUTH_STORE_CONN_STRING"; +pub const ENV_EMAIL_MESSENGER: &str = "SECD_EMAIL_MESSENGER"; +pub const ENV_EMAIL_MESSENGER_CLIENT_ID: &str = "SECD_EMAIL_MESSENGER_CLIENT_ID"; +pub const ENV_EMAIL_MESSENGER_CLIENT_SECRET: &str = "SECD_EMAIL_MESSENGER_CLIENT_SECRET"; + const SESSION_SIZE_BYTES: usize = 32; const SESSION_DURATION: i64 = 60 /* seconds*/ * 60 /* minutes */ * 24 /* hours */ * 360 /* days */; const EMAIL_VALIDATION_DURATION: i64 = 60 /* seconds*/ * 15 /* minutes */; -const VALIDATION_CODE_SIZE: usize = 6; - -const INTERNAL_ERR_MSG: &str = "It seems an invariant was borked or something non-deterministic happened. Please file a bug with secd."; - -#[derive(sqlx::FromRow, Debug, Serialize)] -pub struct ApiKey { - pub public_key: String, - pub private_key: String, -} - -#[derive(sqlx::FromRow, Debug, Serialize)] -pub struct Authorization { - session: Session, -} - -#[derive(sqlx::FromRow, Debug, Serialize)] -pub struct Identity { - #[sqlx(rename = "identity_public_id")] - id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - data: Option<String>, - created_at: OffsetDateTime, - #[serde(skip_serializing_if = "Option::is_none")] - deleted_at: Option<OffsetDateTime>, -} - -#[derive(sqlx::FromRow, Debug, Serialize)] -pub struct Session { - #[sqlx(rename = "identity_public_id")] - pub identity_id: IdentityId, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(default)] - pub secret: Option<SessionSecret>, - #[serde(with = "time::serde::timestamp")] - pub created_at: OffsetDateTime, - #[serde(with = "time::serde::timestamp")] - pub expired_at: OffsetDateTime, - #[serde(skip_serializing_if = "Option::is_none")] - pub revoked_at: Option<OffsetDateTime>, -} +const ADDRESSS_VALIDATION_CODE_SIZE: u8 = 6; +const ADDRESS_VALIDATION_ALLOWS_ATTEMPTS: u8 = 5; +const ADDRESS_VALIDATION_IDENTITY_SURJECTION: bool = true; -#[async_trait::async_trait] -trait Validation { - fn expired(&self) -> bool; - fn is_validated(&self) -> bool; - async fn find_associated_identities( - &self, - store: Arc<dyn Store + Send + Sync>, - ) -> anyhow::Result<Option<Identity>>; - async fn validate( - &mut self, - i: &Identity, - store: Arc<dyn Store + Send + Sync>, - ) -> anyhow::Result<()>; -} +pub type AddressId = Uuid; +pub type AddressValidationId = Uuid; +pub type CredentialId = Uuid; +pub type IdentityId = Uuid; +pub type MotifId = Uuid; +pub type PhoneNumber = String; +pub type RefId = Uuid; +pub type SessionToken = String; -#[async_trait::async_trait] -impl Validation for EmailValidation { - fn expired(&self) -> bool { - let now = OffsetDateTime::now_utc(); - self.expired_at < now - || self.revoked_at.map(|t| t < now).unwrap_or(false) - || self.deleted_at.map(|t| t < now).unwrap_or(false) - } - fn is_validated(&self) -> bool { - self.validated_at - .map(|t| t >= OffsetDateTime::now_utc()) - .unwrap_or(false) - } - async fn find_associated_identities( - &self, - store: Arc<dyn Store + Send + Sync>, - ) -> anyhow::Result<Option<Identity>> { - store.find_identity(None, Some(&self.email_address)).await - } - async fn validate( - &mut self, - i: &Identity, - store: Arc<dyn Store + Send + Sync>, - ) -> anyhow::Result<()> { - self.identity_id = Some(i.id); - self.validated_at = Some(OffsetDateTime::now_utc()); - store.write_email_validation(&self).await?; - Ok(()) - } -} +#[derive(Debug, derive_more::Display, thiserror::Error)] +pub enum SecdError { + AddressValidationFailed, + AddressValidationSessionExchangeFailed, + AddressValidationExpiredOrConsumed, -#[async_trait::async_trait] -impl Validation for OauthValidation { - fn expired(&self) -> bool { - let now = OffsetDateTime::now_utc(); - self.revoked_at.map(|t| t < now).unwrap_or(false) - || self.deleted_at.map(|t| t < now).unwrap_or(false) - } - fn is_validated(&self) -> bool { - self.validated_at - .map(|t| t >= OffsetDateTime::now_utc()) - .unwrap_or(false) - } - async fn find_associated_identities( - &self, - store: Arc<dyn Store + Send + Sync>, - ) -> anyhow::Result<Option<Identity>> { - let oauth_identity = get_oauth_identity_data(&self).await?; + TooManyIdentities, + IdentityNotFound, - let identity = store - .find_identity(None, oauth_identity.email.as_deref()) - .await?; + EmailMessengerError(#[from] EmailMessengerError), + InvalidEmaillAddress(#[from] email_address::Error), - let now = OffsetDateTime::now_utc(); - if let Some(email) = oauth_identity.email.clone() { - let identity = identity.unwrap_or(Identity { - id: Uuid::new_v4(), - data: None, - created_at: OffsetDateTime::now_utc(), - deleted_at: None, - }); - store.write_identity(&identity).await?; - store.write_email(&email).await?; - store - .write_email_validation(&EmailValidation { - id: Some(Uuid::new_v4()), - identity_id: Some(identity.id), - email_address: email, - code: None, - is_oauth_derived: true, - created_at: now, - expired_at: now, - validated_at: Some(now), - revoked_at: None, - deleted_at: None, - }) - .await?; - Ok(Some(identity)) - } else { - Ok(identity) - } - } - async fn validate( - &mut self, - i: &Identity, - store: Arc<dyn Store + Send + Sync>, - ) -> anyhow::Result<()> { - self.identity_id = Some(i.id); - self.validated_at = Some(OffsetDateTime::now_utc()); - store.write_oauth_validation(&self).await?; - Ok(()) - } -} + FailedToProvideSessionIdentity(String), + InvalidSession, -#[derive(Debug, EnumString)] -pub enum ValidationType { - Email, - Oauth, -} - -#[derive(sqlx::FromRow, Debug)] -pub struct EmailValidation { - #[sqlx(rename = "email_validation_public_id")] - id: Option<Uuid>, - #[sqlx(rename = "identity_public_id")] - identity_id: Option<IdentityId>, - #[sqlx(rename = "address")] - email_address: String, - code: Option<String>, - is_oauth_derived: bool, - created_at: OffsetDateTime, - expired_at: OffsetDateTime, - validated_at: Option<OffsetDateTime>, - revoked_at: Option<OffsetDateTime>, - deleted_at: Option<OffsetDateTime>, -} + StoreError(#[from] StoreError), + StoreInitFailure(String), -#[derive(Debug)] -pub struct OauthValidation { - id: Option<Uuid>, - identity_id: Option<IdentityId>, - oauth_provider: OauthProvider, - access_token: Option<String>, - raw_response: Option<String>, - created_at: OffsetDateTime, - validated_at: Option<OffsetDateTime>, - revoked_at: Option<OffsetDateTime>, - deleted_at: Option<OffsetDateTime>, -} - -#[derive(Debug, Clone)] -pub struct OauthProvider { - pub name: OauthProviderName, - pub flow: Option<String>, - pub base_url: Url, - pub response: OauthResponseType, - pub default_scope: String, - pub client_id: String, - pub client_secret: String, - pub redirect_url: Url, - pub created_at: OffsetDateTime, - pub deleted_at: Option<OffsetDateTime>, -} - -#[derive(Debug, Display, Clone, Copy, ValueEnum, EnumString)] -pub enum OauthResponseType { - Code, - IdToken, - None, - Token, + FailedToDecodeInput(#[from] hex::FromHexError), + Todo, } -// TODO: feature gate ValueEnum since it's only needed for iam builds -#[derive(Copy, Display, Clone, Debug, ValueEnum, EnumString)] -pub enum OauthProviderName { - Amazon, - Apple, - Dropbox, - Facebook, - Github, - Gitlab, - Google, - Instagram, - LinkedIn, - Microsoft, - Paypal, - Reddit, - Spotify, - Strava, - Stripe, - Twitch, - Twitter, - WeChat, +pub struct Secd { + store: Arc<dyn Store + Send + Sync + 'static>, + email_messenger: Arc<dyn EmailMessenger + Send + Sync + 'static>, } #[derive(Display, Debug, Serialize, Deserialize, EnumString, EnumVariantNames)] #[strum(ascii_case_insensitive)] pub enum AuthStore { - Sqlite, - Postgres, - MySql, - Mongo, - Dynamo, - Redis, + Sqlite { conn: String }, + Postgres { conn: String }, + Redis { conn: String }, } #[derive(Display, Debug, Serialize, Deserialize, EnumString, EnumVariantNames)] #[strum(ascii_case_insensitive)] -pub enum AuthEmail { - LocalStub, +pub enum AuthEmailMessenger { + Local, Ses, Mailgun, Sendgrid, } -pub type IdentityId = Uuid; -pub type SessionSecret = String; -pub type SessionSecretHash = String; -pub type ValidationRequestId = Uuid; -pub type ValidationSecretCode = String; -pub type OauthRedirectAuthUrl = Url; +#[serde_with::skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct Address { + id: AddressId, + t: AddressType, + #[serde(with = "time::serde::timestamp")] + created_at: OffsetDateTime, +} -#[derive(Debug, derive_more::Display, thiserror::Error)] -pub enum SecdError { - EmailSendError(#[from] EmailMessengerError), - EmailValidationExpiryOverflow, - EmailValidationRequestError, - OauthValidationRequestError, - IdentityIdShouldExistInvariant, - InitializationFailure(sqlx::Error), - InvalidCode, - InvalidEmailAddress, - InputValidation(String), - InternalError(String), - NotImplemented(String), - SessionExpiryOverflow, - Unauthenticated, - Todo, +#[serde_as] +#[serde_with::skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct AddressValidation { + pub id: AddressValidationId, + pub identity_id: Option<IdentityId>, + pub address: Address, + pub method: AddressValidationMethod, + #[serde(with = "time::serde::timestamp")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::timestamp")] + pub expires_at: OffsetDateTime, + #[serde(with = "time::serde::timestamp::option")] + pub revoked_at: Option<OffsetDateTime>, + #[serde(with = "time::serde::timestamp::option")] + pub validated_at: Option<OffsetDateTime>, + pub attempts: i32, + #[serde_as(as = "serde_with::hex::Hex")] + hashed_token: Vec<u8>, + #[serde_as(as = "serde_with::hex::Hex")] + hashed_code: Vec<u8>, } -pub struct Secd { - store: Arc<dyn Store + Send + Sync + 'static>, - email_messenger: Arc<dyn EmailMessenger + Send + Sync + 'static>, +#[derive(Debug, Display, Serialize, EnumString)] +pub enum AddressValidationMethod { + Email, + Sms, + Oauth, } -impl Secd { - /// get_identity - /// - /// Return all information associated with the identity id. - pub async fn get_identity(&self, identity: IdentityId) -> Result<Identity, SecdError> { - unimplemented!() - } - /// get_authorization - /// - /// Return the authorization for this session. If the session is - /// invalid, expired or otherwise unauthenticated, an error will - /// be returned. - pub async fn get_authorization( - &self, - secret: SessionSecret, - ) -> Result<Authorization, SecdError> { - match self.store.read_session(&secret).await { - Ok(session) - if session.expired_at > OffsetDateTime::now_utc() - || session.revoked_at > Some(OffsetDateTime::now_utc()) => - { - Ok(Authorization { session }) - } - Ok(_) => Err(SecdError::Unauthenticated), - Err(_e) => Err(SecdError::Todo), - } - } - /// revoke_session - /// - /// Revokes a session such that it may no longer be used to authenticate - /// the associated identity. - pub async fn revoke_session(&self, secret_hash: SessionSecretHash) -> Result<(), SecdError> { - unimplemented!() - } - /// revoke_identity - /// - /// Soft delete an identity such that all associated resources are - /// deleted as well. - /// - /// NOTE: This operation cannot be undone. Although it may not be undone - /// a separate call to delete_identity is required to cleanup necessary - /// resources. - /// - /// You may configure secd to periodically clean all revoked - /// identities and associated resources with AUTOCLEAN_REVOKED. - pub async fn revoke_identity(&self, identity_id: IdentityId) -> Result<(), SecdError> { - unimplemented!() - } - /// delete_identity - /// - /// Delete an identity and all associated resources (e.g. session, - /// authorization structures, etc...). This is a hard delete and permanently - /// removes all stored information. - /// - /// NOTE: An identity _must_ be revoked before it can be deleted. Otherwise, - /// secd will return an error. - pub async fn delete_identity(&self, identity_id: IdentityId) -> Result<(), SecdError> { - unimplemented!() - } +#[derive(Debug, Display, Serialize, EnumString)] +pub enum AddressType { + Email { email_address: Option<EmailAddress> }, + Sms { phone_number: Option<PhoneNumber> }, +} - // register service - // register service_action(service_id, action) - // list services - // list service actions +#[derive(Debug, Serialize)] +pub struct Credential { + pub id: CredentialId, + pub identity_id: IdentityId, + pub t: CredentialType, +} + +#[serde_as] +#[derive(Debug, Serialize)] +pub enum CredentialType { + Passphrase { + key: String, + value: String, + }, + Oicd { + value: String, + }, + OneTimeCodes { + codes: Vec<String>, + }, + Totp { + #[serde_as(as = "DisplayFromStr")] + url: Url, + code: String, + }, + WebAuthn { + value: String, + }, +} + +#[serde_with::skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct Identity { + pub id: IdentityId, + pub address_validations: Vec<AddressValidation>, + pub credentials: Vec<Credential>, + pub rules: Vec<String>, // TODO: rules for (e.g. mfa reqs) + pub metadata: Option<String>, + #[serde(with = "time::serde::timestamp")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::timestamp::option")] + pub deleted_at: Option<OffsetDateTime>, +} - // create permission - // create group (name, identities) - // create role (name, permissios) - // list group - // list role - // list permission - // describe group - // describe role - // describe permission - // add_identity_to_group - // remove_identity_from_group - // add_permission_to_role - // remove_permission_from_role - // attach_role_to_group - // attach_permission_to_group (just creates single role and attaches it) +#[serde_as] +#[serde_with::skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct Session { + pub identity_id: IdentityId, + #[serde_as(as = "serde_with::hex::Hex")] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub token: Vec<u8>, + #[serde(with = "time::serde::timestamp")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::timestamp")] + pub expired_at: OffsetDateTime, + #[serde(with = "time::serde::timestamp::option")] + pub revoked_at: Option<OffsetDateTime>, } diff --git a/crates/secd/src/util/from.rs b/crates/secd/src/util/from.rs new file mode 100644 index 0000000..bab8a25 --- /dev/null +++ b/crates/secd/src/util/from.rs @@ -0,0 +1,66 @@ +use std::str::FromStr; + +use crate::AuthStore; + +impl From<Option<String>> for AuthStore { + fn from(s: Option<String>) -> Self { + let conn = s.clone().unwrap_or("sqlite::memory:".into()); + match conn.split(":").next() { + Some("postgresql") | Some("postgres") => AuthStore::Postgres { conn }, + Some("sqlite") => AuthStore::Sqlite { conn }, + _ => panic!( + "AuthStore: Invalid database connection string provided. Found: {:?}", + s + ), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn auth_store_from_string() { + let in_conn = None; + let a = AuthStore::from(in_conn.clone()); + match AuthStore::from(in_conn.clone()) { + AuthStore::Sqlite { conn } => assert_eq!(conn, "sqlite::memory:"), + r @ _ => assert!( + false, + "should have parsed None as in-memory sqlite. Found: {:?}", + r + ), + } + + let postgresql_conn = Some("postgresql://testuser:p4ssw0rd@1.2.3.4:5432/test-db".into()); + match AuthStore::from(postgresql_conn.clone()) { + AuthStore::Postgres { conn } => assert_eq!(conn, postgresql_conn.unwrap()), + r @ _ => assert!(false, "should have parsed as postgres. Found: {:?}", r), + } + + let postgres_conn = Some("postgres://testuser:p4ssw0rd@1.2.3.4:5432/test-db".into()); + match AuthStore::from(postgres_conn.clone()) { + AuthStore::Postgres { conn } => assert_eq!(conn, postgres_conn.unwrap()), + r @ _ => assert!(false, "should have parsed as postgres. Found: {:?}", r), + } + + let sqlite_conn = Some("sqlite:///path/to/db.sql".into()); + let a = AuthStore::from(sqlite_conn.clone()); + match AuthStore::from(sqlite_conn.clone()) { + AuthStore::Sqlite { conn } => assert_eq!(conn, sqlite_conn.unwrap()), + r @ _ => assert!(false, "should have parsed as sqlite. Found: {:?}", r), + } + + let sqlite_mem_conn = Some("sqlite:memory:".into()); + let a = AuthStore::from(sqlite_mem_conn.clone()); + match AuthStore::from(sqlite_mem_conn.clone()) { + AuthStore::Sqlite { conn } => assert_eq!(conn, sqlite_mem_conn.unwrap()), + r @ _ => assert!( + false, + "should have parsed as in-memoy sqlite. Found: {:?}", + r + ), + } + } +} diff --git a/crates/secd/src/util/mod.rs b/crates/secd/src/util/mod.rs index bb177cb..6677c2f 100644 --- a/crates/secd/src/util/mod.rs +++ b/crates/secd/src/util/mod.rs @@ -1,38 +1,12 @@ -use std::str::FromStr; +pub(crate) mod from; -use anyhow::{bail, Context}; -use log::error; use rand::distributions::Alphanumeric; use rand::{thread_rng, Rng}; -use reqwest::header; -use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; use url::Url; -use crate::{ - OauthProvider, OauthProviderName, OauthValidation, SecdError, ValidationRequestId, - INTERNAL_ERR_MSG, -}; - -pub(crate) fn log_err(e: Box<dyn std::error::Error>, new_e: SecdError) -> SecdError { - error!("{:?}", e); - new_e -} -pub(crate) fn to_secd_err(e: anyhow::Error, new_e: SecdError) -> SecdError { - error!("{:?}", e); - new_e -} - -pub(crate) fn log_err_sqlx(e: sqlx::Error) -> sqlx::Error { - error!("{:?}", e); - e -} -pub(crate) fn generate_random_url_safe(n: usize) -> String { - thread_rng() - .sample_iter(&Alphanumeric) - .take(n) - .map(char::from) - .collect() -} +use crate::{AddressType, IdentityId, SecdError, Session, SESSION_DURATION, SESSION_SIZE_BYTES}; pub(crate) fn remove_trailing_slash(url: &mut Url) -> String { let mut u = url.to_string(); @@ -44,134 +18,37 @@ pub(crate) fn remove_trailing_slash(url: &mut Url) -> String { u } -pub(crate) fn build_oauth_auth_url( - p: &OauthProvider, - validation_id: ValidationRequestId, -) -> Result<Url, SecdError> { - let redirect_url = remove_trailing_slash(&mut p.redirect_url.clone()); - - Ok(Url::from_str(&format!( - "{}?client_id={}&response_type={}&redirect_uri={}&scope={}&state={}", - p.base_url, - p.client_id, - p.response.to_string().to_lowercase(), - redirect_url, - p.default_scope, - validation_id.to_string() - )) - .map_err(|_| SecdError::InternalError(INTERNAL_ERR_MSG.into()))?) -} - -pub(crate) async fn get_oauth_identity_data( - validation: &OauthValidation, -) -> anyhow::Result<OauthAccessIdentity> { - let provider = validation.oauth_provider.name; - let token = validation - .access_token - .clone() - .ok_or(SecdError::InternalError( - "no access token provided with which to build oauth data url".into(), - ))?; - - let url = Url::from_str(&format!( - "{}{}", - match provider { - OauthProviderName::Google => - "https://www.googleapis.com/oauth2/v2/userinfo?access_token=", - _ => unimplemented!(), - }, - token - ))?; - - let resp = reqwest::get(url).await?.json::<serde_json::Value>().await?; - let identity = match provider { - OauthProviderName::Google => OauthAccessIdentity { - email: resp - .get("email") - .and_then(|v| v.as_str().map(|s| s.to_string())), - email_is_verified: resp.get("verified_email").and_then(|v| v.as_bool()), - picture_url: resp - .get("picture") - .and_then(|v| Url::from_str(&v.to_string()).ok()), - }, - _ => unimplemented!(), - }; - - Ok(identity) -} - -#[derive(Debug, Serialize)] -pub(crate) struct OauthAccessTokenGoogleRequest { - grant_type: String, - code: String, - client_id: String, - client_secret: String, - redirect_uri: String, -} - -#[derive(Debug, Deserialize)] -pub(crate) struct OauthAccessTokenGoogleResponse { - access_token: String, - expires_in: i32, - token_type: String, - scope: String, - id_token: String, +pub(crate) fn hash(i: &[u8]) -> Vec<u8> { + let mut hasher = Sha256::new(); + hasher.update(i); + hasher.finalize().to_vec() } -#[derive(Debug)] -pub(crate) struct OauthAccessIdentity { - pub(crate) email: Option<String>, - pub(crate) email_is_verified: Option<bool>, - pub(crate) picture_url: Option<Url>, -} - -type AccessTokenRequestData = String; - -pub(crate) async fn get_oauth_access_token( - validation: &OauthValidation, - secret_code: &String, -) -> anyhow::Result<String> { - let provider = validation.oauth_provider.name; - - let url = Url::from_str(match provider { - OauthProviderName::Google => "https://accounts.google.com/o/oauth2/token", - _ => unimplemented!(), - })?; - - let request_data = serde_json::to_string(&match provider { - OauthProviderName::Google => OauthAccessTokenGoogleRequest { - grant_type: "authorization_code".to_string(), - code: secret_code.to_string(), - client_id: validation.oauth_provider.client_id.clone(), - client_secret: validation.oauth_provider.client_secret.clone(), - redirect_uri: remove_trailing_slash( - &mut validation.oauth_provider.redirect_url.clone(), - ), - }, - _ => unimplemented!(), - })?; - - let r = reqwest::Client::new() - .post(url) - .body(request_data) - .header(header::CONTENT_TYPE, "application/json") - .send() - .await - .context(format!( - "Failed to successfully POST a new access token for: {}", - provider - ))?; - - let access_token = match provider { - OauthProviderName::Google => { - let resp: OauthAccessTokenGoogleResponse = r.json().await.context(format!( - "Failed to parse access token response for: {}", - provider - ))?; - resp.access_token +impl AddressType { + pub fn get_value(&self) -> Option<String> { + match &self { + AddressType::Email { email_address } => { + email_address.as_ref().map(|a| a.to_string().clone()) + } + AddressType::Sms { phone_number } => phone_number.as_ref().cloned(), } - _ => unimplemented!(), - }; + } +} - Ok(access_token) +impl Session { + pub(crate) fn new(identity_id: IdentityId) -> Result<Self, SecdError> { + let token = (0..SESSION_SIZE_BYTES) + .map(|_| rand::random::<u8>()) + .collect::<Vec<u8>>(); + let now = OffsetDateTime::now_utc(); + Ok(Session { + identity_id, + token, + created_at: now, + expired_at: now + .checked_add(time::Duration::new(SESSION_DURATION, 0)) + .ok_or(SecdError::Todo)?, + revoked_at: None, + }) + } } diff --git a/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql b/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql deleted file mode 100644 index 3d4d84c..0000000 --- a/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql +++ /dev/null @@ -1,86 +0,0 @@ -create extension if not exists pgcrypto; -create extension if not exists citext; -create schema if not exists secd; - -create table if not exists secd.identity ( - identity_id bigserial primary key - , identity_public_id uuid - , data text - , created_at timestamptz not null - , deleted_at timestamptz - , unique(identity_public_id) -); - -create table if not exists secd.session ( - session_id bigserial primary key - , identity_id bigint not null references secd.identity(identity_id) - , secret_hash bytea not null - , created_at timestamptz not null - , expired_at timestamptz - , revoked_at timestamptz - , unique(secret_hash) -); - -create table if not exists secd.oauth_provider ( - oauth_provider_id serial primary key - , name text not null - , flow text not null - , base_url text not null - , response_type text not null - , default_scope text - , client_id text not null - , client_secret text not null - , redirect_url text not null - , created_at timestamptz not null - , deleted_at timestamptz - , unique (name, flow) -); - -create table if not exists secd.oauth_validation ( - oauth_validation_id bigserial primary key - , oauth_validation_public_id uuid not null - , oauth_provider_id integer not null references secd.oauth_provider(oauth_provider_id) - , access_token text - , raw_response text - , created_at timestamptz not null - , validated_at timestamptz - , unique (oauth_validation_public_id) -); - -create table if not exists secd.identity_oauth_validation ( - identity_oauth_validation_id bigserial primary key - -- A validation does not require an identity to initiate - , identity_id bigint references secd.identity(identity_id) - , oauth_validation_id bigint not null references secd.oauth_validation(oauth_validation_id) - , revoked_at timestamptz - , deleted_at timestamptz - , unique(identity_id, oauth_validation_id) -); - -create table if not exists secd.email ( - email_id bigserial primary key - , address text not null - , unique(address) -); - -create table if not exists secd.email_validation ( - email_validation_id bigserial primary key - , email_validation_public_id uuid not null - , email_id bigint not null references secd.email(email_id) - , code text - , is_oauth_derived boolean not null - , created_at timestamptz not null - , validated_at timestamptz - , expired_at timestamptz - , unique(email_validation_public_id) -); - -create table if not exists secd.identity_email_validation ( - identity_email_validation_id bigserial primary key - -- A validation does not require an identity to initiate - , identity_id bigint references secd.identity(identity_id) - , email_validation_id bigint not null references secd.email_validation(email_validation_id) - , revoked_at timestamptz - , deleted_at timestamptz - , unique(identity_id, email_validation_id) -); diff --git a/crates/secd/store/pg/migrations/20221222002434_bootstrap.sql b/crates/secd/store/pg/migrations/20221222002434_bootstrap.sql new file mode 100644 index 0000000..2b89957 --- /dev/null +++ b/crates/secd/store/pg/migrations/20221222002434_bootstrap.sql @@ -0,0 +1,85 @@ +create extension if not exists pgcrypto; +create extension if not exists citext; +create schema if not exists secd; + +create table if not exists secd.realm ( + realm_id bigserial primary key + , created_at timestamptz not null +); + +create table if not exists secd.realm_data ( + realm_data_id bigserial primary key + , realm_id bigint not null references secd.realm(realm_id) + , email_provider jsonb not null + , sms_provider jsonb not null + , created_at timestamptz not null + , deleted_at timestamptz +); + +create table if not exists secd.identity ( + identity_id bigserial primary key + , identity_public_id uuid not null + , data jsonb -- some things are dervied, others are not + , created_at timestamptz not null + , updated_at timestamptz not null + , deleted_at timestamptz + , unique(identity_public_id) +); + +create table if not exists secd.credential ( + credential_id bigserial primary key + , credential_public_id uuid not null + , identity_id bigint not null references secd.identity(identity_id) + , type text not null-- e.g. password, oidc, totop, lookup_secret, webauthn, ... + , data jsonb not null + , version integer not null + , created_at timestamptz not null + , revoked_at timestamptz + , deleted_at timestamptz +); + +create table if not exists secd.address ( + address_id bigserial primary key + , address_public_id uuid not null + , type text not null + , value text not null + , created_at timestamptz not null + , unique(value, type) +); + +create table if not exists secd.address_validation ( + address_validation_id bigserial primary key + , address_validation_public_id uuid not null + , identity_id bigint references secd.identity(identity_id) + , address_id bigint not null references secd.address(address_id) + , method text not null -- e.g. email, sms, voice, oidc + , token_hash bytea + , code_hash bytea + , attempts integer not null + , created_at timestamptz not null + , expires_at timestamptz not null + , revoked_at timestamptz + , validated_at timestamptz + , unique(address_validation_public_id) +); + +create table if not exists secd.session ( + session_id bigserial primary key + , identity_id bigint not null references secd.identity(identity_id) + , token_hash bytea not null + , created_at timestamptz not null + , expired_at timestamptz not null + , revoked_at timestamptz + , unique(token_hash) +); + +create table if not exists secd.message ( + message_id bigserial primary key + , address_id bigint not null references secd.address(address_id) + , subject text + , body text + , template text not null + , template_vars jsonb not null + , created_at timestamptz not null + , sent_at timestamptz +); diff --git a/crates/secd/store/pg/sql/find_address.sql b/crates/secd/store/pg/sql/find_address.sql new file mode 100644 index 0000000..5eaafbb --- /dev/null +++ b/crates/secd/store/pg/sql/find_address.sql @@ -0,0 +1,8 @@ +select address_public_id + , type + , value + , created_at +from secd.address +where (($1::uuid is null) or (address_public_id = $1)) +and (($2::text is null) or (type = $2)) +and (($3::text is null) or (value = $3)); diff --git a/crates/secd/store/pg/sql/find_address_validation.sql b/crates/secd/store/pg/sql/find_address_validation.sql new file mode 100644 index 0000000..3874994 --- /dev/null +++ b/crates/secd/store/pg/sql/find_address_validation.sql @@ -0,0 +1,18 @@ +select av.address_validation_public_id + , i.identity_public_id + , a.address_public_id + , a.type + , a.value + , a.created_at + , av.method + , av.token_hash + , av.code_hash + , av.attempts + , av.created_at + , av.expires_at + , av.revoked_at + , av.validated_at +from secd.address_validation av +join secd.address a using(address_id) +left join secd.identity i using(identity_id) +where (($1::uuid is null) or (address_validation_public_id = $1)); diff --git a/crates/secd/store/pg/sql/find_email_validation.sql b/crates/secd/store/pg/sql/find_email_validation.sql deleted file mode 100644 index 1eb3e43..0000000 --- a/crates/secd/store/pg/sql/find_email_validation.sql +++ /dev/null @@ -1,18 +0,0 @@ -select - ev.email_validation_public_id - , i.identity_public_id - , e.address - , ev.code - , ev.is_oauth_derived - , ev.created_at - , ev.expired_at - , ev.validated_at - , iev.revoked_at - , iev.deleted_at -from secd.email_validation ev -join secd.email e using (email_id) -left join secd.identity_email_validation iev using (email_validation_id) -left join secd.identity i using (identity_id) -where (($1 is null) or (email_validation_public_id = $1)) -and (($2 is null) or (code = $2)); --- diff --git a/crates/secd/store/pg/sql/find_identity.sql b/crates/secd/store/pg/sql/find_identity.sql index 135ff9a..37105cb 100644 --- a/crates/secd/store/pg/sql/find_identity.sql +++ b/crates/secd/store/pg/sql/find_identity.sql @@ -1,11 +1,15 @@ -select - identity_public_id - , data - , i.created_at - , i.deleted_at -from secd.identity i -join secd.identity_email_validation iev using (identity_id) -join secd.email_validation ev using (email_validation_id) -join secd.email e using (email_id) -where (($1 is null) or (i.identity_public_id = $1)) -and (($2 is null) or (e.address = $2)); +select distinct + identity_public_id + , data::text + , i.created_at + , i.updated_at + , i.deleted_at +from secd.identity i +left join secd.address_validation av using (identity_id) +left join secd.address a using (address_id) +left join secd.session s using (identity_id) +where (($1::uuid is null) or (i.identity_public_id = $1)) +and (($2::text is null) or (a.value = $2)) +and (($3::bool is null) or (($3::bool is true) and (av.validated_at is not null))) +and (($4::bytea is null) or (s.token_hash = $4)) +and i.deleted_at is null; diff --git a/crates/secd/store/pg/sql/find_identity_by_code.sql b/crates/secd/store/pg/sql/find_identity_by_code.sql deleted file mode 100644 index e5a0970..0000000 --- a/crates/secd/store/pg/sql/find_identity_by_code.sql +++ /dev/null @@ -1,11 +0,0 @@ -select identity_email_validation_id -from secd.email_validation -where email_validation_public_id = $1::uuid --- -select - identity_public_id - , data - , i.created_at -from secd.identity i -left join secd.identity_email ie using (identity_id) -where ie.identity_email_validation_id = $1; diff --git a/crates/secd/store/pg/sql/find_session.sql b/crates/secd/store/pg/sql/find_session.sql new file mode 100644 index 0000000..ca58480 --- /dev/null +++ b/crates/secd/store/pg/sql/find_session.sql @@ -0,0 +1,11 @@ +select distinct + i.identity_public_id + , s.created_at + , s.expired_at + , s.revoked_at +from secd.session s +join secd.identity i using (identity_id) +where (($1::bytea is null) or (s.token_hash = $1)) +and (($2::uuid is null) or (i.identity_public_id = $2)) +and (($3::timestamptz is null) or (s.expired_at > $3)) +and ((revoked_at is null) or ($4::timestamptz is null) or (s.revoked_at > $4)); diff --git a/crates/secd/store/pg/sql/read_email_raw_id.sql b/crates/secd/store/pg/sql/read_email_raw_id.sql deleted file mode 100644 index 6604fb0..0000000 --- a/crates/secd/store/pg/sql/read_email_raw_id.sql +++ /dev/null @@ -1 +0,0 @@ -select email_id from secd.email where address = $1 diff --git a/crates/secd/store/pg/sql/read_identity.sql b/crates/secd/store/pg/sql/read_identity.sql deleted file mode 100644 index e69de29..0000000 --- a/crates/secd/store/pg/sql/read_identity.sql +++ /dev/null diff --git a/crates/secd/store/pg/sql/read_identity_raw_id.sql b/crates/secd/store/pg/sql/read_identity_raw_id.sql deleted file mode 100644 index 5b5d95c..0000000 --- a/crates/secd/store/pg/sql/read_identity_raw_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -select identity_id from secd.identity where identity_public_id = $1; --- diff --git a/crates/secd/store/pg/sql/read_oauth_provider.sql b/crates/secd/store/pg/sql/read_oauth_provider.sql deleted file mode 100644 index edaa114..0000000 --- a/crates/secd/store/pg/sql/read_oauth_provider.sql +++ /dev/null @@ -1,12 +0,0 @@ -select flow - , base_url - , response_type - , default_scope - , client_id - , client_secret - , redirect_url - , created_at - , deleted_at -from secd.oauth_provider -where name = $1 -and flow = $2; diff --git a/crates/secd/store/pg/sql/read_oauth_validation.sql b/crates/secd/store/pg/sql/read_oauth_validation.sql deleted file mode 100644 index d8361ea..0000000 --- a/crates/secd/store/pg/sql/read_oauth_validation.sql +++ /dev/null @@ -1,23 +0,0 @@ -select oauth_validation_public_id - , i.identity_public_id - , ov.access_token - , ov.raw_response - , ov.created_at - , ov.validated_at - , iov.revoked_at - , iov.deleted_at - , op.name as oauth_provider_name - , op.flow as oauth_provider_flow - , op.base_url as oauth_provider_base_url - , op.response_type as oauth_provider_response_type - , op.default_scope as oauth_provider_default_scope - , op.client_id as oauth_provider_client_id - , op.client_secret as oauth_provider_client_secret - , op.redirect_url as oauth_provider_redirect_url - , op.created_at as oauth_provider_created_at - , op.deleted_at as oauth_provider_deleted_at -from secd.oauth_validation ov -join secd.oauth_provider op using(oauth_provider_id) -left join secd.identity_oauth_validation iov using(oauth_validation_id) -left join secd.identity i using(identity_id) -where oauth_validation_public_id = $1; diff --git a/crates/secd/store/pg/sql/read_session.sql b/crates/secd/store/pg/sql/read_session.sql deleted file mode 100644 index b1f98d4..0000000 --- a/crates/secd/store/pg/sql/read_session.sql +++ /dev/null @@ -1,8 +0,0 @@ -select - i.identity_public_id - , s.created_at - , s.expired_at - , s.revoked_at -from secd.session s -join secd.identity i using (identity_id) -where secret_hash = $1; diff --git a/crates/secd/store/pg/sql/read_validation_type.sql b/crates/secd/store/pg/sql/read_validation_type.sql deleted file mode 100644 index 2eceb98..0000000 --- a/crates/secd/store/pg/sql/read_validation_type.sql +++ /dev/null @@ -1,7 +0,0 @@ -select 'Email' -from secd.email_validation -where email_validation_public_id = $1 -union -select 'Oauth' -from secd.oauth_validation -where oauth_validation_public_id = $1; diff --git a/crates/secd/store/pg/sql/write_address.sql b/crates/secd/store/pg/sql/write_address.sql new file mode 100644 index 0000000..da1bf3a --- /dev/null +++ b/crates/secd/store/pg/sql/write_address.sql @@ -0,0 +1,8 @@ +insert into secd.address ( + address_public_id + , type + , value + , created_at +) values ( + $1, $2, $3, $4 +); diff --git a/crates/secd/store/pg/sql/write_address_validation.sql b/crates/secd/store/pg/sql/write_address_validation.sql new file mode 100644 index 0000000..3be830e --- /dev/null +++ b/crates/secd/store/pg/sql/write_address_validation.sql @@ -0,0 +1,27 @@ +insert into secd.address_validation ( + address_validation_public_id + , identity_id + , address_id + , "method" + , token_hash + , code_hash + , attempts + , created_at + , expires_at + , revoked_at + , validated_at +) values( + $1 + , ( + select identity_id from secd.identity where identity_public_id = $2 + ) + , ( + select address_id from secd.address where address_public_id = $3 + ) + , $4, $5, $6, $7, $8, $9, $10, $11 +) on conflict (address_validation_public_id) do update + set identity_id = excluded.identity_id + , attempts = excluded.attempts + , revoked_at = excluded.revoked_at + , validated_at = excluded.validated_at +returning (xmax = 0); diff --git a/crates/secd/store/pg/sql/write_email.sql b/crates/secd/store/pg/sql/write_email.sql deleted file mode 100644 index 06a1dc5..0000000 --- a/crates/secd/store/pg/sql/write_email.sql +++ /dev/null @@ -1,6 +0,0 @@ -insert into secd.email ( - address -) values ( - $1 -) on conflict (address) do nothing -returning email_id; diff --git a/crates/secd/store/pg/sql/write_email_validation.sql b/crates/secd/store/pg/sql/write_email_validation.sql deleted file mode 100644 index ff25b87..0000000 --- a/crates/secd/store/pg/sql/write_email_validation.sql +++ /dev/null @@ -1,43 +0,0 @@ -insert into secd.email_validation - ( - email_validation_public_id - , email_id - , code - , is_oauth_derived - , created_at - , validated_at - , expired_at - ) -values ( - $1 - , $2 - , $3 - , $4 - , $5 - , $6 - , $7 -) on conflict (email_validation_public_id) do update - set validated_at = excluded.validated_at - , expired_at = excluded.expired_at; --- -insert into secd.identity_email_validation ( - identity_id - , email_validation_id - , revoked_at - , deleted_at -) values ( - ( - select identity_id - from secd.identity - where identity_public_id = $1 - ) - , ( - select email_validation_id - from secd.email_validation - where email_validation_public_id = $2 - ) - , $3 - , $4 -) on conflict (identity_id, email_validation_id) do update - set revoked_at = excluded.revoked_at - , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/pg/sql/write_identity.sql b/crates/secd/store/pg/sql/write_identity.sql index 94a51fe..67662a6 100644 --- a/crates/secd/store/pg/sql/write_identity.sql +++ b/crates/secd/store/pg/sql/write_identity.sql @@ -1,11 +1,12 @@ insert into secd.identity ( - identity_public_id, - data, - created_at + identity_public_id + , data + , created_at + , updated_at + , deleted_at ) values ( - $1, - $2, - $3 -) on conflict(identity_public_id) do update + $1, $2::jsonb, $3, $4, $5 +) on conflict (identity_public_id) do update set data = excluded.data + , updated_at = excluded.updated_at , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/pg/sql/write_oauth_provider.sql b/crates/secd/store/pg/sql/write_oauth_provider.sql deleted file mode 100644 index ba69857..0000000 --- a/crates/secd/store/pg/sql/write_oauth_provider.sql +++ /dev/null @@ -1,25 +0,0 @@ -insert into secd.oauth_provider ( - oauth_provider_id - , name - , flow - , base_url - , response_type - , default_scope - , client_id - , client_secret - , redirect_url - , created_at - , deleted_at -) values ( - default - , $1 - , $2 - , $3 - , $4 - , $5 - , $6 - , $7 - , $8 - , $9 - , $10 -) on conflict (name, flow) do nothing; diff --git a/crates/secd/store/pg/sql/write_oauth_validation.sql b/crates/secd/store/pg/sql/write_oauth_validation.sql deleted file mode 100644 index 11f2578..0000000 --- a/crates/secd/store/pg/sql/write_oauth_validation.sql +++ /dev/null @@ -1,45 +0,0 @@ -insert into secd.oauth_validation ( - oauth_validation_public_id - , oauth_provider_id - , access_token - , raw_response - , created_at - , validated_at -) values ( - $1 - , ( - select oauth_provider_id - from secd.oauth_provider - where name = $2 - and flow = $3 - ) - , $4 - , $5 - , $6 - , $7 -) on conflict (oauth_validation_public_id) do update - set access_token = excluded.access_token - , validated_at = excluded.validated_at - , raw_response = excluded.raw_response; --- -insert into secd.identity_oauth_validation ( - identity_id - , oauth_validation_id - , revoked_at - , deleted_at -) values ( - ( - select identity_id - from secd.identity - where identity_public_id = $1 - ) - , ( - select oauth_validation_id - from secd.oauth_validation - where oauth_validation_public_id = $2 - ) - , $3 - , $4 -) on conflict (identity_id, oauth_validation_id) do update - set revoked_at = excluded.revoked_at - , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/pg/sql/write_session.sql b/crates/secd/store/pg/sql/write_session.sql index 1b238c6..18dc1f1 100644 --- a/crates/secd/store/pg/sql/write_session.sql +++ b/crates/secd/store/pg/sql/write_session.sql @@ -1,15 +1,10 @@ insert into secd.session ( identity_id - , secret_hash + , token_hash , created_at , expired_at , revoked_at ) values ( - (select identity_id from secd.identity where identity_public_id = $1) - , $2 - , $3 - , $4 - , $5 -) on conflict (secret_hash) do update - set revoked_at = excluded.revoked_at; --- + (select identity_id from secd.identity where identity_public_id = $1) + , $2, $3, $4, $5 +); diff --git a/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql index a8784f5..299f282 100644 --- a/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql +++ b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql @@ -1,82 +1,81 @@ -create table if not exists identity ( - identity_id integer primary key autoincrement - , identity_public_id uuid - , data text - , created_at timestamptz not null - , deleted_at timestamptz - , unique(identity_public_id) +create table if not exists realm ( + realm_id integer primary key + , created_at integer not null ); -create table if not exists session ( - session_id integer primary key autoincrement - , identity_id bigint not null references identity(identity_id) - , secret_hash bytea not null - , created_at timestamptz not null - , expired_at timestamptz - , revoked_at timestamptz - , unique(secret_hash) +create table if not exists realm_data ( + realm_data_id integer primary key + , realm_id integer not null references realm(realm_id) + , email_provider text not null + , sms_provider text not null + , created_at integer not null + , deleted_at integer ); -create table if not exists oauth_provider ( - oauth_provider_id integer primary key autoincrement - , name text not null - , flow text not null - , base_url text not null - , response_type text not null - , default_scope text - , client_id text not null - , client_secret text not null - , redirect_url text not null - , created_at timestamptz not null - , deleted_at timestamptz - , unique (name, flow) +create table if not exists identity ( + identity_id integer primary key + , identity_public_id uuid not null + , data text -- some things are dervied, others are not + , created_at integer not null + , updated_at integer not null + , deleted_at integer + , unique(identity_public_id) ); -create table if not exists oauth_validation ( - oauth_validation_id integer primary key autoincrement - , oauth_validation_public_id uuid not null - , oauth_provider_id integer not null references oauth_provider(oauth_provider_id) - , access_token text - , raw_response text - , created_at timestamptz not null - , validated_at timestamptz - , unique (oauth_validation_public_id) +create table if not exists credential ( + credential_id integer primary key + , credential_public_id uuid not null + , identity_id integer not null references identity(identity_id) + , type text not null-- e.g. password, oidc, totop, lookup_secret, webauthn, ... + , data text not null + , version integer not null + , created_at integer not null + , revoked_at integer + , deleted_at integer ); -create table if not exists identity_oauth_validation ( - identity_oauth_validation_id integer primary key autoincrement - -- A validation does not require an identity to initiate - , identity_id bigint references identity(identity_id) - , oauth_validation_id bigint not null references oauth_validation(oauth_validation_id) - , revoked_at timestamptz - , deleted_at timestamptz - , unique(identity_id, oauth_validation_id) +create table if not exists address ( + address_id integer primary key + , address_public_id uuid not null + , type text not null + , value text not null + , created_at integer not null + , unique(value, type) ); -create table if not exists email ( - email_id integer primary key autoincrement - , address text not null - , unique(address) +create table if not exists address_validation ( + address_validation_id integer primary key + , address_validation_public_id uuid not null + , identity_id integer references identity(identity_id) + , address_id integer not null references address(address_id) + , method text not null -- e.g. email, sms, voice, oidc + , token_hash blob + , code_hash blob + , attempts integer not null + , created_at integer not null + , expires_at integer not null + , revoked_at integer + , validated_at integer + , unique(address_validation_public_id) ); -create table if not exists email_validation ( - email_validation_id integer primary key autoincrement - , email_validation_public_id uuid not null - , email_id bigint not null references email(email_id) - , code text - , is_oauth_derived boolean not null - , created_at timestamptz not null - , validated_at timestamptz - , expired_at timestamptz - , unique(email_validation_public_id) +create table if not exists session ( + session_id integer primary key + , identity_id integer not null references identity(identity_id) + , token_hash blob not null + , created_at integer not null + , expired_at integer not null + , revoked_at integer + , unique(token_hash) ); -create table if not exists identity_email_validation ( - identity_email_validation_id integer primary key autoincrement - -- A validation does not require an identity to initiate - , identity_id bigint references identity(identity_id) - , email_validation_id bigint not null references email_validation(email_validation_id) - , revoked_at timestamptz - , deleted_at timestamptz - , unique(identity_id, email_validation_id) +create table if not exists message ( + message_id integer primary key + , address_id integer not null references address(address_id) + , subject text + , body text + , template text not null + , template_vars text not null + , created_at integer not null + , sent_at integer ); diff --git a/crates/secd/store/sqlite/sql/find_address.sql b/crates/secd/store/sqlite/sql/find_address.sql new file mode 100644 index 0000000..da1df81 --- /dev/null +++ b/crates/secd/store/sqlite/sql/find_address.sql @@ -0,0 +1,8 @@ +select address_public_id + , type + , value + , created_at +from address +where (($1 is null) or (address_public_id = $1)) +and (($2 is null) or (type = $2)) +and (($3 is null) or (value = $3)); diff --git a/crates/secd/store/sqlite/sql/find_address_validation.sql b/crates/secd/store/sqlite/sql/find_address_validation.sql new file mode 100644 index 0000000..81d2cdf --- /dev/null +++ b/crates/secd/store/sqlite/sql/find_address_validation.sql @@ -0,0 +1,18 @@ +select av.address_validation_public_id + , i.identity_public_id + , a.address_public_id + , a.type + , a.value + , a.created_at + , av.method + , av.token_hash + , av.code_hash + , av.attempts + , av.created_at + , av.expires_at + , av.revoked_at + , av.validated_at +from address_validation av +join address a using(address_id) +left join identity i using(identity_id) +where (($1 is null) or (address_validation_public_id = $1)); diff --git a/crates/secd/store/sqlite/sql/find_email_validation.sql b/crates/secd/store/sqlite/sql/find_email_validation.sql deleted file mode 100644 index d7f311c..0000000 --- a/crates/secd/store/sqlite/sql/find_email_validation.sql +++ /dev/null @@ -1,18 +0,0 @@ -select - ev.email_validation_public_id - , i.identity_public_id - , e.address - , ev.code - , ev.is_oauth_derived - , ev.created_at - , ev.expired_at - , ev.validated_at - , iev.revoked_at - , iev.deleted_at -from email_validation ev -join email e using (email_id) -left join identity_email_validation iev using (email_validation_id) -left join identity i using (identity_id) -where ((?1 is null) or (email_validation_public_id = ?1)) -and ((?2 is null) or (code = ?2)); --- diff --git a/crates/secd/store/sqlite/sql/find_identity.sql b/crates/secd/store/sqlite/sql/find_identity.sql index f94e7b1..1528407 100644 --- a/crates/secd/store/sqlite/sql/find_identity.sql +++ b/crates/secd/store/sqlite/sql/find_identity.sql @@ -1,11 +1,15 @@ -select - identity_public_id - , data - , i.created_at - , i.deleted_at -from identity i -join identity_email_validation iev using (identity_id) -join email_validation ev using (email_validation_id) -join email e using (email_id) -where ((?1 is null) or (i.identity_public_id = ?1)) -and ((?2 is null) or (e.address = ?2)); +select distinct + identity_public_id + , data + , i.created_at + , i.updated_at + , i.deleted_at +from identity i +left join address_validation av using (identity_id) +left join address a using (address_id) +left join session s using (identity_id) +where (($1 is null) or (i.identity_public_id = $1)) +and (($2 is null) or (a.value = $2)) +and (($3 is null) or (($3 is true) and (av.validated_at is not null))) +and (($4 is null) or (s.token_hash = $4)) +and i.deleted_at is null; diff --git a/crates/secd/store/sqlite/sql/find_identity_by_code.sql b/crates/secd/store/sqlite/sql/find_identity_by_code.sql deleted file mode 100644 index b70a13a..0000000 --- a/crates/secd/store/sqlite/sql/find_identity_by_code.sql +++ /dev/null @@ -1,11 +0,0 @@ -select identity_email_validation_id -from email_validation -where email_validation_public_id = $1::uuid --- -select - identity_public_id - , data - , i.created_at -from identity i -left join identity_email ie using (identity_id) -where ie.identity_email_validation_id = ?1; diff --git a/crates/secd/store/sqlite/sql/find_session.sql b/crates/secd/store/sqlite/sql/find_session.sql new file mode 100644 index 0000000..31640dd --- /dev/null +++ b/crates/secd/store/sqlite/sql/find_session.sql @@ -0,0 +1,11 @@ +select distinct + i.identity_public_id + , s.created_at + , s.expired_at + , s.revoked_at +from session s +join identity i using (identity_id) +where (($1 is null) or (s.token_hash = $1)) +and (($2 is null) or (i.identity_public_id = $2)) +and (($3 is null) or (s.expired_at > $3)) +and ((revoked_at is null) or ($4 is null) or (s.revoked_at > $4)); diff --git a/crates/secd/store/sqlite/sql/read_email_raw_id.sql b/crates/secd/store/sqlite/sql/read_email_raw_id.sql deleted file mode 100644 index a65c717..0000000 --- a/crates/secd/store/sqlite/sql/read_email_raw_id.sql +++ /dev/null @@ -1 +0,0 @@ -select email_id from email where address = ?1 diff --git a/crates/secd/store/sqlite/sql/read_identity.sql b/crates/secd/store/sqlite/sql/read_identity.sql deleted file mode 100644 index e69de29..0000000 --- a/crates/secd/store/sqlite/sql/read_identity.sql +++ /dev/null diff --git a/crates/secd/store/sqlite/sql/read_identity_raw_id.sql b/crates/secd/store/sqlite/sql/read_identity_raw_id.sql deleted file mode 100644 index 2bdb718..0000000 --- a/crates/secd/store/sqlite/sql/read_identity_raw_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -select identity_id from identity where identity_public_id = ?1; --- diff --git a/crates/secd/store/sqlite/sql/read_oauth_provider.sql b/crates/secd/store/sqlite/sql/read_oauth_provider.sql deleted file mode 100644 index 5c33cf0..0000000 --- a/crates/secd/store/sqlite/sql/read_oauth_provider.sql +++ /dev/null @@ -1,12 +0,0 @@ -select flow - , base_url - , response_type - , default_scope - , client_id - , client_secret - , redirect_url - , created_at - , deleted_at -from oauth_provider -where name = ?1 -and flow = ?2; diff --git a/crates/secd/store/sqlite/sql/read_oauth_validation.sql b/crates/secd/store/sqlite/sql/read_oauth_validation.sql deleted file mode 100644 index 75f5a94..0000000 --- a/crates/secd/store/sqlite/sql/read_oauth_validation.sql +++ /dev/null @@ -1,23 +0,0 @@ -select oauth_validation_public_id - , i.identity_public_id - , ov.access_token - , ov.raw_response - , ov.created_at - , ov.validated_at - , iov.revoked_at - , iov.deleted_at - , op.name as oauth_provider_name - , op.flow as oauth_provider_flow - , op.base_url as oauth_provider_base_url - , op.response_type as oauth_provider_response_type - , op.default_scope as oauth_provider_default_scope - , op.client_id as oauth_provider_client_id - , op.client_secret as oauth_provider_client_secret - , op.redirect_url as oauth_provider_redirect_url - , op.created_at as oauth_provider_created_at - , op.deleted_at as oauth_provider_deleted_at -from oauth_validation ov -join oauth_provider op using(oauth_provider_id) -left join identity_oauth_validation iov using(oauth_validation_id) -left join identity i using(identity_id) -where oauth_validation_public_id = ?1; diff --git a/crates/secd/store/sqlite/sql/read_session.sql b/crates/secd/store/sqlite/sql/read_session.sql deleted file mode 100644 index c415c4c..0000000 --- a/crates/secd/store/sqlite/sql/read_session.sql +++ /dev/null @@ -1,8 +0,0 @@ -select - i.identity_public_id - , s.created_at - , s.expired_at - , s.revoked_at -from session s -join identity i using (identity_id) -where secret_hash = ?1; diff --git a/crates/secd/store/sqlite/sql/read_validation_type.sql b/crates/secd/store/sqlite/sql/read_validation_type.sql deleted file mode 100644 index cc02ead..0000000 --- a/crates/secd/store/sqlite/sql/read_validation_type.sql +++ /dev/null @@ -1,7 +0,0 @@ -select 'Email' -from email_validation -where email_validation_public_id = ?1 -union -select 'Oauth' -from oauth_validation -where oauth_validation_public_id = ?1; diff --git a/crates/secd/store/sqlite/sql/write_address.sql b/crates/secd/store/sqlite/sql/write_address.sql new file mode 100644 index 0000000..56dab0c --- /dev/null +++ b/crates/secd/store/sqlite/sql/write_address.sql @@ -0,0 +1,8 @@ +insert into address ( + address_public_id + , type + , value + , created_at +) values ( + $1, $2, $3, $4 +); diff --git a/crates/secd/store/sqlite/sql/write_address_validation.sql b/crates/secd/store/sqlite/sql/write_address_validation.sql new file mode 100644 index 0000000..67ce916 --- /dev/null +++ b/crates/secd/store/sqlite/sql/write_address_validation.sql @@ -0,0 +1,26 @@ +insert into address_validation ( + address_validation_public_id + , identity_id + , address_id + , "method" + , token_hash + , code_hash + , attempts + , created_at + , expires_at + , revoked_at + , validated_at +) values( + $1 + , ( + select identity_id from identity where identity_public_id = $2 + ) + , ( + select address_id from address where address_public_id = $3 + ) + , $4, $5, $6, $7, $8, $9, $10, $11 +) on conflict (address_validation_public_id) do update + set identity_id = excluded.identity_id + , attempts = excluded.attempts + , revoked_at = excluded.revoked_at + , validated_at = excluded.validated_at; diff --git a/crates/secd/store/sqlite/sql/write_email.sql b/crates/secd/store/sqlite/sql/write_email.sql deleted file mode 100644 index a64aed4..0000000 --- a/crates/secd/store/sqlite/sql/write_email.sql +++ /dev/null @@ -1,6 +0,0 @@ -insert into email ( - address -) values ( - $1 -) on conflict (address) do nothing -returning email_id; diff --git a/crates/secd/store/sqlite/sql/write_email_validation.sql b/crates/secd/store/sqlite/sql/write_email_validation.sql deleted file mode 100644 index d839310..0000000 --- a/crates/secd/store/sqlite/sql/write_email_validation.sql +++ /dev/null @@ -1,43 +0,0 @@ -insert into email_validation - ( - email_validation_public_id - , email_id - , code - , is_oauth_derived - , created_at - , validated_at - , expired_at - ) -values ( - ?1 - , ?2 - , ?3 - , ?4 - , ?5 - , ?6 - , ?7 -) on conflict (email_validation_public_id) do update - set validated_at = excluded.validated_at - , expired_at = excluded.expired_at; --- -insert into identity_email_validation ( - identity_id - , email_validation_id - , revoked_at - , deleted_at -) values ( - ( - select identity_id - from identity - where identity_public_id = ?1 - ) - , ( - select email_validation_id - from email_validation - where email_validation_public_id = ?2 - ) - , ?3 - , ?4 -) on conflict (identity_id, email_validation_id) do update - set revoked_at = excluded.revoked_at - , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/sqlite/sql/write_identity.sql b/crates/secd/store/sqlite/sql/write_identity.sql index 8cf46c5..aa59358 100644 --- a/crates/secd/store/sqlite/sql/write_identity.sql +++ b/crates/secd/store/sqlite/sql/write_identity.sql @@ -1,11 +1,12 @@ insert into identity ( - identity_public_id, - data, - created_at + identity_public_id + , data + , created_at + , updated_at + , deleted_at ) values ( - ?1, - ?2, - ?3 -) on conflict(identity_public_id) do update + $1, $2, $3, $4, $5 +) on conflict (identity_public_id) do update set data = excluded.data + , updated_at = excluded.updated_at , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/sqlite/sql/write_oauth_provider.sql b/crates/secd/store/sqlite/sql/write_oauth_provider.sql deleted file mode 100644 index 421caf7..0000000 --- a/crates/secd/store/sqlite/sql/write_oauth_provider.sql +++ /dev/null @@ -1,23 +0,0 @@ -insert into oauth_provider ( - name - , flow - , base_url - , response_type - , default_scope - , client_id - , client_secret - , redirect_url - , created_at - , deleted_at -) values ( - ?1 - , ?2 - , ?3 - , ?4 - , ?5 - , ?6 - , ?7 - , ?8 - , ?9 - , ?10 -) on conflict (name, flow) do nothing; diff --git a/crates/secd/store/sqlite/sql/write_oauth_validation.sql b/crates/secd/store/sqlite/sql/write_oauth_validation.sql deleted file mode 100644 index ccb11aa..0000000 --- a/crates/secd/store/sqlite/sql/write_oauth_validation.sql +++ /dev/null @@ -1,45 +0,0 @@ -insert into oauth_validation ( - oauth_validation_public_id - , oauth_provider_id - , access_token - , raw_response - , created_at - , validated_at -) values ( - ?1 - , ( - select oauth_provider_id - from oauth_provider - where name = ?2 - and flow = ?3 - ) - , ?4 - , ?5 - , ?6 - , ?7 -) on conflict (oauth_validation_public_id) do update - set access_token = excluded.access_token - , validated_at = excluded.validated_at - , raw_response = excluded.raw_response; --- -insert into identity_oauth_validation ( - identity_id - , oauth_validation_id - , revoked_at - , deleted_at -) values ( - ( - select identity_id - from identity - where identity_public_id = ?1 - ) - , ( - select oauth_validation_id - from oauth_validation - where oauth_validation_public_id = ?2 - ) - , ?3 - , ?4 -) on conflict (identity_id, oauth_validation_id) do update - set revoked_at = excluded.revoked_at - , deleted_at = excluded.deleted_at; diff --git a/crates/secd/store/sqlite/sql/write_session.sql b/crates/secd/store/sqlite/sql/write_session.sql index 480af54..4679912 100644 --- a/crates/secd/store/sqlite/sql/write_session.sql +++ b/crates/secd/store/sqlite/sql/write_session.sql @@ -1,15 +1,10 @@ insert into session ( identity_id - , secret_hash + , token_hash , created_at , expired_at , revoked_at ) values ( - (select identity_id from identity where identity_public_id = ?1) - , ?2 - , ?3 - , ?4 - , ?5 -) on conflict (secret_hash) do update - set revoked_at = excluded.revoked_at; --- + (select identity_id from identity where identity_public_id = $1) + , $2, $3, $4, $5 +); diff --git a/crates/secd/tests/authn_integration.rs b/crates/secd/tests/authn_integration.rs deleted file mode 100644 index d823d5a..0000000 --- a/crates/secd/tests/authn_integration.rs +++ /dev/null @@ -1,35 +0,0 @@ -#[cfg(test)] -mod test { - use std::error::Error; - - use secd::{AuthEmail, AuthStore, Secd}; - - #[tokio::test] - async fn email_authentication_int() -> Result<(), Box<dyn Error>> { - let secd = Secd::init(AuthStore::Sqlite, None, AuthEmail::LocalStub, None, None).await?; - let v_id = secd.create_validation_request_email("b@g.com").await?; - - // TODO: in memory mailbox backed by sqlite which just throws them in temporarily... - // and then I can grab it? - - // Things to test - // 1. after exchanging the session, I cannot get it again - // 1. a validation can only be used once - // 1. a session can be used to retrieve identity information - assert_eq!(1, 2); - Ok(()) - } - - #[tokio::test] - async fn oauth_authentication_int() -> Result<(), Box<dyn Error>> { - let secd = Secd::init(AuthStore::Sqlite, None, AuthEmail::LocalStub, None, None).await?; - - // Things to test - // 1. after exchanging the session, I cannot get it again - // 1. a validation can only be used once - // 1. a session can be used to retrieve identity information - // 1. an oauth session links with an existing emails session - assert_eq!(1, 2); - Ok(()) - } -} @@ -1,7 +1,7 @@ run-debug: @RUST_BACKTRACE=1 cargo run $@ -build-dev: +build: @cargo build build-prod: @@ -9,3 +9,6 @@ build-prod: start-postgres: @docker start secd-db || docker run -d --name secd-db -e POSTGRES_PASSWORD=p4ssw0rd -e POSTGRES_USER=secduser -e POSTGRES_DB=secd -p 5412:5432 postgres:12 -c log_statement=all + +start-mailserver: + @docker start mailhog || docker run -d --name mailhog -p 7180:8025 -p 25:1025 mailhog/mailhog:latest |
