aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorbenj <benj@rse8.com>2022-12-01 10:30:34 -0800
committerbenj <benj@rse8.com>2022-12-01 10:35:50 -0800
commit2c4eb2d311919ad9fb70738199ecf99bf20c9fce (patch)
tree8739dd9d1d0c07fc27df2ece3d21f3a03db7397b /crates
parentaa8c20d501b58001a5e1b24964c62363e2112ff8 (diff)
downloadsecdiam-2c4eb2d311919ad9fb70738199ecf99bf20c9fce.tar
secdiam-2c4eb2d311919ad9fb70738199ecf99bf20c9fce.tar.gz
secdiam-2c4eb2d311919ad9fb70738199ecf99bf20c9fce.tar.bz2
secdiam-2c4eb2d311919ad9fb70738199ecf99bf20c9fce.tar.lz
secdiam-2c4eb2d311919ad9fb70738199ecf99bf20c9fce.tar.xz
secdiam-2c4eb2d311919ad9fb70738199ecf99bf20c9fce.tar.zst
secdiam-2c4eb2d311919ad9fb70738199ecf99bf20c9fce.zip
- basic functionality with psql and sqlite
- cli helper tool
Diffstat (limited to '')
-rw-r--r--crates/iam/Cargo.toml21
-rw-r--r--crates/iam/src/api.rs498
-rw-r--r--crates/iam/src/command.rs164
-rw-r--r--crates/iam/src/main.rs287
-rw-r--r--crates/iam/src/util.rs88
-rw-r--r--crates/secd/Cargo.toml23
-rw-r--r--crates/secd/build.rs (renamed from build.rs)0
-rw-r--r--crates/secd/src/client/email.rs62
-rw-r--r--crates/secd/src/client/mod.rs209
-rw-r--r--crates/secd/src/client/sqldb.rs424
-rw-r--r--crates/secd/src/lib.rs409
-rw-r--r--crates/secd/src/util/mod.rs21
-rw-r--r--crates/secd/store/pg/migrations/20221116062550_bootstrap.sql49
-rw-r--r--crates/secd/store/pg/sql/find_email_validation.sql17
-rw-r--r--crates/secd/store/pg/sql/find_identity.sql9
-rw-r--r--crates/secd/store/pg/sql/find_identity_by_code.sql11
-rw-r--r--crates/secd/store/pg/sql/read_email_raw_id.sql1
-rw-r--r--crates/secd/store/pg/sql/read_identity.sql0
-rw-r--r--crates/secd/store/pg/sql/read_identity_raw_id.sql2
-rw-r--r--crates/secd/store/pg/sql/read_session.sql8
-rw-r--r--crates/secd/store/pg/sql/write_email.sql11
-rw-r--r--crates/secd/store/pg/sql/write_email_validation.sql27
-rw-r--r--crates/secd/store/pg/sql/write_identity.sql9
-rw-r--r--crates/secd/store/pg/sql/write_session.sql18
-rw-r--r--crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql45
-rw-r--r--crates/secd/store/sqlite/sql/find_email_validation.sql16
-rw-r--r--crates/secd/store/sqlite/sql/find_identity.sql9
-rw-r--r--crates/secd/store/sqlite/sql/find_identity_by_code.sql11
-rw-r--r--crates/secd/store/sqlite/sql/read_email_raw_id.sql1
-rw-r--r--crates/secd/store/sqlite/sql/read_identity.sql0
-rw-r--r--crates/secd/store/sqlite/sql/read_identity_raw_id.sql2
-rw-r--r--crates/secd/store/sqlite/sql/read_session.sql8
-rw-r--r--crates/secd/store/sqlite/sql/write_email.sql11
-rw-r--r--crates/secd/store/sqlite/sql/write_email_validation.sql27
-rw-r--r--crates/secd/store/sqlite/sql/write_identity.sql1
-rw-r--r--crates/secd/store/sqlite/sql/write_session.sql18
36 files changed, 2517 insertions, 0 deletions
diff --git a/crates/iam/Cargo.toml b/crates/iam/Cargo.toml
new file mode 100644
index 0000000..fd9006d
--- /dev/null
+++ b/crates/iam/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "iam"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+anyhow = "1.0"
+async-std = { version = "1.12.0", features = [ "attributes" ] }
+clap = { version = "4.0.29", features = ["derive"] }
+colored = "2.0.0"
+home = "0.5.4"
+log = "0.4"
+rand = "0.8"
+secd = { path = "../secd" }
+serde = "1"
+serde_json = { version = "1.0", features = ["raw_value"] }
+strum = "0.24.1"
+strum_macros = "0.24"
+toml = "0.5.9"
+thiserror = "1.0"
+uuid = { version = "1.2", features = ["v4", "serde"]} \ No newline at end of file
diff --git a/crates/iam/src/api.rs b/crates/iam/src/api.rs
new file mode 100644
index 0000000..5819533
--- /dev/null
+++ b/crates/iam/src/api.rs
@@ -0,0 +1,498 @@
+use crate::ISSUE_TRACKER_LOC;
+use clap::{Parser, Subcommand, ValueEnum};
+use colored::*;
+use serde::{Deserialize, Serialize};
+use thiserror;
+use uuid::Uuid;
+
+#[derive(Debug, thiserror::Error)]
+pub enum CliError {
+ #[error("{}", "iam failed to read a valid configuration profile. Initialize an iam store with `iam admin init`".red())]
+ InvalidProfile,
+ #[error("{} {}", "Failed to initialize secd: ".red(), .0.yellow())]
+ SecdInitializationFailure(String),
+ #[error("{} {}", "Fail to initialize an iam store.".red(), format!("An invariant was likely broken and should be reported as a bug here: {}", ISSUE_TRACKER_LOC))]
+ AdminInitializationError,
+ #[error("{} {}", "The provided validation id and code is invalid or has expired.".red(), "You may recieve at most one session with a valid code, after which a new validation is required.")]
+ InvalidCode,
+ #[error("{} {}", "An unknown error occurred.".red(), format!("An invariant was likely broken and should be reported as a bug here: {}", ISSUE_TRACKER_LOC))]
+ Unknown,
+}
+
+#[derive(Parser)]
+#[command(
+ name = "iam",
+ author = "benabel",
+ version = "0.0.1",
+ long_about = "SecD IAM\nIdentity and access management secured by secd and user controlled. Get started with `iam init`"
+)]
+pub struct Args {
+ #[command(subcommand)]
+ pub command: Command,
+ /// IAM backend profile as defined in the iam config or as a connection string to an auth store.
+ #[arg(long, short)]
+ pub profile: Option<String>,
+ /// The store type for the associated connection string
+ #[arg(long, short)]
+ pub store_type: Option<StoreType>,
+ /// Connection string for the IAM store
+ #[arg(long, short)]
+ pub connection_string: Option<String>,
+}
+
+#[derive(Clone, ValueEnum)]
+pub enum StoreType {
+ Dynamo,
+ Memory,
+ Mysql,
+ Postgres,
+ Redis,
+ Sqlite,
+}
+
+#[derive(Subcommand)]
+pub enum Command {
+ #[command(
+ about = "Administrative actions for this IAM instance",
+ long_about = "Admin\n\nAdministrative actions for this IAM instance. Each IAM instance is defined by the specified backend in the iam config or manually as an optional argument"
+ )]
+ Admin {
+ #[command(subcommand)]
+ action: AdminAction,
+ },
+ #[command(
+ about = "Create a new IAM object which may be used to enforce authorization schemes.",
+ long_about = "Create\n\nEntities which define the structure of an IAM instance. These entities may be rendered as an IAM graph, or within a web view, to more easily visualize and manipulate the IAM instance."
+ )]
+ Create {
+ #[command(subcommand)]
+ object: CreateObject,
+ },
+ #[command(
+ about = "Get details for a specific IAM object",
+ long_about = "Get\n\nGet details for a specific IAM object"
+ )]
+ Get {
+ #[command(subcommand)]
+ object: GetObject,
+ },
+ #[command(
+ about = "Initialize an IAM store (alias for `iam admin init`)",
+ long_about = "Init\n\nInitalize a new IAM admin store and save the store's configuration profile. This command is an alias for, and thus equiavlent to, `iam admin init`."
+ )]
+ Init {
+ /// If true, interactively initialize an IAM store. Otherwise output a template config.
+ #[arg(long, short, action)]
+ interactive: bool,
+ },
+ #[command(
+ about = "Link multiple IAM objects together",
+ long_about = "Link\n\nCleave different IAM entities to create an IAM system."
+ )]
+ Link {
+ #[command(subcommand)]
+ object: LinkObject,
+ /// Unlink the provided entities rather than link them.
+ #[arg(long, short, action)]
+ unlink: bool,
+ },
+ #[command(
+ about = "List and filter IAM objects",
+ long_about = "List\n\nPage through collections of IAM objects with optional filtering"
+ )]
+ Ls {
+ #[command(subcommand)]
+ object: ListObject,
+ /// Regex filter for entity names
+ #[arg(long, short)]
+ name: Option<String>,
+ /// Only fetch entities created after this time
+ #[arg(long, short)]
+ after: Option<i64>,
+ /// Only fetch entities created before this time
+ #[arg(long, short)]
+ before: Option<i64>,
+ },
+ /// Start the iam repl to more easily interact with iam and its primitives
+ Repl,
+}
+
+#[derive(Subcommand)]
+pub enum AdminAction {
+ /// Aliased as `iam init`
+ Init {
+ /// If true, interactively initialize an IAM store. Otherwise output a template config.
+ #[arg(long, short, action)]
+ interactive: bool,
+ },
+ /// Configure, describe, or rotate the default IAM store.
+ Backend {
+ #[command(subcommand)]
+ action: AdminBackendAction,
+ },
+ /// Create a new administrative entity for an IAM store.
+ Create {
+ #[command(subcommand)]
+ object: AdminObject,
+ },
+ /// Seal the configured IAM store to prevent administrative changes
+ Seal,
+ /// Unseal the configured IAM store to make administrative changes
+ Unseal {
+ /// The secret key used to seal this store
+ secret_key: String,
+ },
+}
+
+#[derive(Subcommand)]
+pub enum AdminBackendAction {
+ Configure {
+ name: String,
+ store: StoreType,
+ connection: String,
+ },
+ Switch {
+ name: String,
+ },
+}
+
+#[derive(Subcommand)]
+pub enum AdminObject {
+ /// An email template used for IAM procedures, including identity validation
+ EmailTemplate {
+ template_type: EmailTemplateType,
+ template: String,
+ },
+ /// A selected provider capable of sending email messages
+ EmailProvider {
+ provider: EmailProvider,
+ secret_key: String,
+ public_key: Option<String>,
+ },
+ /// A selected Oauth2.0 provider capable of authenticating identities
+ OauthProvider {
+ provider: OauthProvider,
+ client_id: String,
+ secret: String,
+ redirect_uri: String,
+ },
+ /// A selected provider capable of sending SMS
+ SmsProvider {
+ provider: SmsProvider,
+ secret_key: String,
+ public_key: Option<String>,
+ },
+ /// A new secret which may be used to unseal the IAM store
+ StoreSecret,
+ /// A selected provider capable of sending automated voice messages
+ VoiceProvider {
+ provider: VoiceProvider,
+ secret_key: String,
+ public_key: Option<String>,
+ },
+}
+
+#[derive(Clone, ValueEnum)]
+pub enum EmailTemplateType {
+ Login,
+ SignUp,
+}
+
+#[derive(Clone, ValueEnum)]
+pub enum EmailProvider {
+ Custom,
+ Mailgun,
+ Sendgrid,
+ Ses,
+}
+
+#[derive(Clone, ValueEnum)]
+pub enum SmsProvider {
+ AwsSns,
+ Custom,
+ Twilio,
+}
+
+#[derive(Clone, ValueEnum)]
+pub enum VoiceProvider {
+ Custom,
+ Twilio,
+}
+
+#[derive(Subcommand)]
+pub enum CreateObject {
+ #[command(
+ about = "A set of long-lived tokens which authorize an identity",
+ long_about = "Api Keys\n\nApi keys are long lived identifiers which authenticate and authorize a identity. Keys have a public and private part,\nwhich may be shared and must be kept private, respectively. Unlike sessions, api keys may be long-lived (infinite) or\nset to expire within certain timeframes."
+ )]
+ ApiKey {
+ /// Identity against which this api key will be linked
+ identity: Uuid,
+ /// Time this api key expires (epoch time)
+ expires_at: Option<i64>,
+ },
+ #[command(
+ about = "A collection of identities",
+ long_about = "Group\n\nA group may be created to operate simultaneously against a collection of identities. An identity may be part of mutliple groups, but it may not be part of the same group more than once."
+ )]
+ Group {
+ /// The unique name for this group
+ name: String,
+ /// An optional set of identities to link against this group
+ identities: Vec<Uuid>,
+ },
+ #[command(
+ about = "A collection of services and service actions",
+ long_about = "Permission\n\nA permission may be created to operate simultaneously against a collection of services and service actions. A service or service action may be part of mutliple permissions, but it may not be part of the same permission more than once. A permission may be used when many services and service actions are linked and unlinked against a role."
+ )]
+ Permission {
+ /// An optional set of services to link against this permission
+ #[arg(long, short)]
+ services: Vec<Uuid>,
+ /// An optional set of service actions to link against this permission
+ #[arg(long, short)]
+ actions: Vec<Uuid>,
+ },
+ #[command(
+ about = "A collection of permissions",
+ long_about = "Role\n\nA role may be created to operate simultaneously against a collection of permissions. A permission may be part of mutliple roles, but it may not be part of the same role more than once. A role may be used when many entities (such as groups or identities) are linked and unlinked against many permissions."
+ )]
+ Role {
+ /// The unique name for this role
+ name: String,
+ /// An optional set of permissions to link against this role
+ permissions: Vec<Uuid>,
+ },
+ #[command(
+ about = "An entity for which an action may be authorized",
+ long_about = "Service\n\nA service is an atomic entity which requires authorization. While a service's authorization may be subdivided by service actions, a service represents a logical element of authorization separation."
+ )]
+ Service {
+ /// The unique name for this service
+ name: String,
+ /// URI for this service which may be used to resolve authorization
+ #[arg(long, short)]
+ uri: Option<String>,
+ },
+ #[command(
+ about = "A specific authorization action by a service",
+ long_about = "Service Action\n\nA service action is a domain specific action which defines what an identity authorization within that service. A service action may be a simple boolean value or a more complex express which is evaluated at runtime. For example, a boolean action may be something like `can_read_salary_table`, and a more complex action may be `readable_table_rows(datetime)` which executes at runtime and returns a value (or list of values) the service may use to determine authorization. Service actions are used as an inversion of control pattern to ensure that services do not need to worry about specific authorization actions for identities. A service action is unnecessary if the service has no specific authorization logic."
+ )]
+ ServiceAction {
+ /// The unique name for this service action
+ name: String,
+ /// Program executed for this service action
+ #[arg(long, short)]
+ program: Option<String>,
+ },
+ #[command(
+ about = "A timebound token which authorizes an identity",
+ long_about = "Session\n\nA session is an opaque timebound token which allows an identity to authorize against IAM services. The session may be created by providing a validation request id and secret challenge code"
+ )]
+ Session {
+ /// The validation id associated with a non-expired valid validation
+ #[arg(long, short)]
+ validation_id: Uuid,
+ /// The secret code associated with this validation.
+ #[arg(long, short)]
+ secret_code: String,
+ },
+ #[command(
+ about = "An action which initiates an identity 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 {
+ /// Method by which the validation will occur
+ #[command(subcommand)]
+ 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>,
+ },
+}
+
+#[derive(Subcommand)]
+pub enum ValidationMethod {
+ /// An email address to which the validation will be sent
+ Email {
+ /// 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: OauthProvider,
+ /// An optional scope to use for authorization
+ scope: Option<String>,
+ },
+ /// 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(Clone, ValueEnum)]
+pub enum OauthProvider {
+ Amazon,
+ Apple,
+ Dropbox,
+ Facebook,
+ Github,
+ Gitlab,
+ Google,
+ Instagram,
+ LinkedIn,
+ Microsoft,
+ Paypal,
+ Reddit,
+ Spotify,
+ Strava,
+ Stripe,
+ Twitch,
+ Twitter,
+ WeChat,
+}
+
+#[derive(Subcommand)]
+pub enum GetObject {
+ ApiKey {
+ /// Public key associated with this api key set
+ public_key: String,
+ },
+ Group {
+ /// Unique group name
+ name: String,
+ /// Unique group id
+ #[arg(long, short)]
+ id: Option<Uuid>,
+ },
+ Identity {
+ /// Unique identity id
+ id: Uuid,
+ },
+ Permission {
+ /// Unique permission name
+ name: String,
+ /// Unique permission id
+ #[arg(long, short)]
+ id: Option<Uuid>,
+ },
+ Role {
+ /// Unique role name
+ name: String,
+ /// Unique role id
+ #[arg(long, short)]
+ id: Option<Uuid>,
+ },
+ Session {
+ /// The plaintext token which uniquely identifies the session
+ secret: String,
+ },
+ Service {
+ /// Unique service name
+ name: String,
+ /// Unique service id
+ #[arg(long, short)]
+ id: Option<Uuid>,
+ },
+ ServiceAction {
+ /// Unique service action name
+ name: String,
+ /// Unique service action id
+ #[arg(long, short)]
+ id: Option<Uuid>,
+ },
+ Validation {
+ /// Unique validation request id
+ id: Uuid,
+ },
+}
+
+#[derive(Subcommand)]
+pub enum LinkObject {
+ Group {
+ group_name: String,
+ #[arg(short, long, alias = "id")]
+ group_id: Option<Uuid>,
+
+ identity_ids: Vec<Uuid>,
+ },
+ Identity {
+ identity_id: Uuid,
+
+ group_names: Vec<String>,
+ #[arg(long)]
+ group_ids: Vec<Uuid>,
+ },
+ Permission {
+ permission_name: String,
+ #[arg(short, long, alias = "id")]
+ permission_id: Option<Uuid>,
+
+ role_names: Vec<String>,
+ #[arg(long)]
+ role_ids: Vec<Uuid>,
+ },
+ Role {
+ role_name: String,
+ #[arg(short, long, alias = "id")]
+ role_id: Option<Uuid>,
+
+ permission_names: Vec<String>,
+ #[arg(long)]
+ permission_ids: Vec<Uuid>,
+ },
+ Service {
+ service_name: String,
+ #[arg(short, long, alias = "id")]
+ service_id: Option<Uuid>,
+
+ permission_names: Vec<String>,
+ #[arg(long)]
+ permission_ids: Vec<Uuid>,
+ },
+ ServiceAction {
+ service_action_name: String,
+ #[arg(short, long, alias = "id")]
+ service_action_id: Option<Uuid>,
+
+ service_name: Vec<String>,
+ #[arg(long)]
+ service_ids: Vec<Uuid>,
+ },
+}
+
+#[derive(Subcommand)]
+pub enum ListObject {
+ ApiKey,
+ Group,
+ Identity,
+ Permission,
+ Role,
+ Session,
+ Service,
+ ServiceAction,
+ Validation,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct Config {
+ pub profile: Vec<ConfigProfile>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct ConfigProfile {
+ pub name: String,
+ pub store: secd::AuthStore,
+ pub store_conn: String,
+ pub emailer: secd::AuthEmail,
+ 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
new file mode 100644
index 0000000..e9e0f23
--- /dev/null
+++ b/crates/iam/src/command.rs
@@ -0,0 +1,164 @@
+use crate::{
+ api,
+ util::{self, get_config_profile, Result},
+ CONFIG_LOGIN_TEMPLATE, CONFIG_SIGNUP_TEMPLATE,
+};
+use async_std::fs;
+use colored::*;
+use rand::distributions::{Alphanumeric, DistString};
+use secd::{AuthEmail, AuthStore};
+use std::{
+ fs::File,
+ io::{self, stdin, stdout, Write},
+ str::FromStr,
+};
+use strum::VariantNames;
+
+const DEFAULT_LOGIN_EMAIL: &str = "<!doctype html><html><body><p>You requested a login link for %secd_email_address%. Please click the following link<br/><br/>http://localhost:5500/myapp/iam/exchange/%secd_link%<br/><br/>or use code: %secd_code%</p></body></html>";
+const DEFAULT_SIGNUP_EMAIL: &str = "<!doctype html><html><body><h1>Welcome to SecD IAM</h1></h1><p>If you did not request this sign up, you can safely ignore this email. Otherwise, please click the following link to validate your account<br/><br/>http://localhost:5500/myapp/iam/exchange/%secd_link%<br/><br/>or use code: %secd_code%</p></body></html>";
+
+pub async fn admin_init(is_interactive: bool) -> Result<()> {
+ let config_dir = util::get_config_dir();
+ let config_profile = get_config_profile();
+ fs::create_dir_all(config_dir.clone()).await?;
+
+ if config_profile.try_exists()? {
+ writeln!(
+ io::stdout(),
+ "{} {}",
+ config_profile.clone().display(),
+ "already exists and there is nothing to initialize. To create a new IAM store use `iam admin create store` or modify the configuration profile directly"
+ .yellow()
+ )?;
+ } else {
+ writeln!(stdout(), "{}", "creating default profile".green())?;
+
+ let mut login_template = config_dir.clone();
+ login_template.push(CONFIG_LOGIN_TEMPLATE);
+ let mut f = File::create(login_template.clone())?;
+ f.write_all(DEFAULT_LOGIN_EMAIL.as_bytes())?;
+
+ let mut signup_template = config_dir.clone();
+ signup_template.push(CONFIG_SIGNUP_TEMPLATE);
+ f = File::create(signup_template.clone())?;
+ f.write_all(DEFAULT_SIGNUP_EMAIL.as_bytes())?;
+
+ let mut cfg = api::Config {
+ profile: vec![api::ConfigProfile {
+ name: "default".to_string(),
+ store: AuthStore::Sqlite,
+ 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,
+ email_template_login: Some(login_template.display().to_string()),
+ email_template_signup: Some(signup_template.display().to_string()),
+ }],
+ };
+
+ let mut input: String = String::new();
+ if is_interactive {
+ writeln!(stdout(), "{}", "For a complete overview of configuration options, cancel the initialization and explore `iam help`")?;
+ write!(stdout(), "Would you like to create a default local store with local stubs for external services?[(y)es/(n)o]: ")?;
+ stdout().flush()?;
+ 'outer: loop {
+ input.clear();
+ stdin().read_line(&mut input)?;
+ match input.as_str().trim() {
+ "Y" | "y" | "Yes" | "yes" => break,
+ "N" | "n" | "No" | "no" => {
+ loop {
+ write!(
+ stdout(),
+ "Persistence store {:?}: ",
+ AuthStore::VARIANTS
+ .iter()
+ .map(|s| s.to_lowercase())
+ .collect::<Vec<String>>()
+ )?;
+ stdout().flush()?;
+ input.clear();
+ stdin().read_line(&mut input)?;
+ match AuthStore::from_str(&input.trim()) {
+ Ok(s) => {
+ cfg.profile[0].store = s;
+ break;
+ }
+ Err(_) => {
+ writeln!(stdout(), "{}", "Invalid store type".red())?;
+ }
+ }
+ }
+
+ write!(stdout(), "Store connection string: ")?;
+ stdout().flush()?;
+ input.clear();
+ stdin().read_line(&mut input)?;
+ cfg.profile[0].store_conn = input.trim().to_string().clone();
+
+ loop {
+ write!(
+ stdout(),
+ "Email provider {:?}: ",
+ AuthEmail::VARIANTS
+ .iter()
+ .map(|s| s.to_lowercase())
+ .collect::<Vec<String>>()
+ )?;
+ stdout().flush()?;
+ input.clear();
+ stdin().read_line(&mut input)?;
+ match AuthEmail::from_str(&input.trim()) {
+ Ok(s) => {
+ cfg.profile[0].emailer = s;
+ break;
+ }
+ Err(_) => {
+ writeln!(stdout(), "{}", "Invalid email provider".red())?;
+ }
+ }
+ }
+
+ write!(
+ stdout(),
+ "Email template for login validation:[FilePath or Enter for default]: "
+ )?;
+ stdout().flush()?;
+ input.clear();
+ stdin().read_line(&mut input)?;
+ cfg.profile[0].email_template_login =
+ Some(input.trim().to_string().clone());
+
+ write!(
+ stdout(),
+ "Email template for signup validation:[FilePath or Enter for default]: "
+ )?;
+ stdout().flush()?;
+ input.clear();
+ stdin().read_line(&mut input)?;
+ cfg.profile[0].email_template_login =
+ Some(input.trim().to_string().clone());
+
+ break 'outer;
+ }
+ _ => {}
+ }
+ }
+ }
+
+ let mut f = File::create(config_profile.clone())?;
+ f.write_all(toml::to_string(&cfg)?.as_bytes())?;
+ writeln!(
+ stdout(),
+ "{} {} {} {} {}",
+ "created iam config".green(),
+ "default",
+ "at".green(),
+ config_dir.display().to_string(),
+ "to hold secD iam configurations".green()
+ )?;
+ }
+ Ok(())
+}
diff --git a/crates/iam/src/main.rs b/crates/iam/src/main.rs
new file mode 100644
index 0000000..c187380
--- /dev/null
+++ b/crates/iam/src/main.rs
@@ -0,0 +1,287 @@
+mod api;
+mod command;
+mod util;
+
+use api::{AdminAction, Args, CliError, Command, CreateObject, GetObject, LinkObject, ListObject};
+use clap::Parser;
+use secd::{Secd, SecdError};
+use util::Result;
+
+use crate::api::ValidationMethod;
+
+const CONFIG_DIR_NAME: &str = "secdiam";
+const CONFIG_PROFILE_FILE: &str = "profiles.toml";
+const CONFIG_LOGIN_TEMPLATE: &str = "default_login.html";
+const CONFIG_SIGNUP_TEMPLATE: &str = "default_signup.html";
+const ISSUE_TRACKER_LOC: &str = "https://www.github.com/secdiam/iam";
+
+#[async_std::main]
+async fn main() {
+ match exec().await {
+ Ok(Some(s)) => println!("{}", s),
+ Err(e) => {
+ println!("{}", e);
+ std::process::exit(1);
+ }
+ _ => {}
+ }
+}
+
+async fn exec() -> Result<Option<String>> {
+ let args = Args::parse();
+ Ok(match args.command {
+ Command::Init { interactive } => admin(AdminAction::Init { interactive })
+ .await
+ .map_err(|_| CliError::AdminInitializationError)?,
+ Command::Admin { action } => admin(action).await?,
+
+ 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()))?;
+
+ match rest {
+ Command::Create { object } => create(&secd, object).await?,
+ Command::Get { object } => get(&secd, object).await?,
+ Command::Link { object, unlink } => link(&secd, object, unlink).await?,
+ Command::Ls {
+ object,
+ name,
+ before,
+ after,
+ } => list(&secd, object, name, before, after).await?,
+ Command::Repl => {
+ unimplemented!()
+ }
+ _ => None,
+ }
+ }
+ })
+}
+
+async fn admin(cmd: AdminAction) -> Result<Option<String>> {
+ Ok(match cmd {
+ AdminAction::Backend { action } => {
+ println!("do backend stuff!");
+ None
+ }
+ AdminAction::Create { object } => {
+ println!("do create!");
+ None
+ }
+ AdminAction::Init { interactive } => {
+ command::admin_init(interactive)
+ .await
+ .map_err(|_| CliError::AdminInitializationError)?;
+ None
+ }
+ AdminAction::Seal => {
+ println!("do seal");
+ None
+ }
+ AdminAction::Unseal { secret_key } => {
+ println!("do unseal: {}", secret_key);
+ None
+ }
+ })
+}
+async fn create(secd: &Secd, cmd: CreateObject) -> Result<Option<String>> {
+ Ok(match cmd {
+ CreateObject::ApiKey {
+ identity,
+ expires_at,
+ } => {
+ println!("create object");
+ None
+ }
+ CreateObject::Group { name, identities } => {
+ println!("create group");
+ None
+ }
+ CreateObject::Permission { services, actions } => {
+ println!("create permission");
+ None
+ }
+ CreateObject::Role { name, permissions } => {
+ println!("create role");
+ None
+ }
+ CreateObject::Service { name, uri } => {
+ println!("create service");
+ None
+ }
+ CreateObject::ServiceAction { name, program } => {
+ println!("create service action");
+ None
+ }
+ 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::Unknown,
+ })?;
+ serde_json::to_string(&session).ok()
+ }
+ CreateObject::Validation { method, identity } => match method {
+ ValidationMethod::Email { address } => {
+ secd.create_validation_request(Some(&address)).await?;
+ None
+ }
+ _ => unimplemented!(),
+ },
+ })
+}
+async fn get(secd: &Secd, cmd: GetObject) -> Result<Option<String>> {
+ Ok(match cmd {
+ GetObject::ApiKey { public_key } => {
+ println!("get object api key");
+ None
+ }
+ GetObject::Group { name, id } => {
+ println!("get object group");
+ None
+ }
+ GetObject::Identity { id } => {
+ println!("get object identity");
+ None
+ }
+ GetObject::Permission { name, id } => {
+ println!("get object permission");
+ None
+ }
+ GetObject::Role { name, id } => {
+ println!("get object role");
+ None
+ }
+ GetObject::Service { name, id } => {
+ println!("get object service");
+ None
+ }
+ GetObject::ServiceAction { name, id } => {
+ println!("get object service action");
+ None
+ }
+ GetObject::Session { secret } => {
+ println!("get object session");
+ None
+ }
+ GetObject::Validation { id } => {
+ println!("get object validation");
+ None
+ }
+ })
+}
+async fn link(secd: &Secd, cmd: LinkObject, should_unlink: bool) -> Result<Option<String>> {
+ Ok(match cmd {
+ LinkObject::Group {
+ group_name,
+ group_id,
+ identity_ids,
+ } => {
+ println!("link object group");
+ None
+ }
+ LinkObject::Identity {
+ identity_id,
+ group_names,
+ group_ids,
+ } => {
+ println!("link object identity");
+ None
+ }
+ LinkObject::Permission {
+ permission_name,
+ permission_id,
+ role_names,
+ role_ids,
+ } => {
+ println!("link object permission");
+ None
+ }
+ LinkObject::Role {
+ role_name,
+ role_id,
+ permission_names,
+ permission_ids,
+ } => {
+ println!("link object role");
+ None
+ }
+ LinkObject::Service {
+ service_name,
+ service_id,
+ permission_names,
+ permission_ids,
+ } => {
+ println!("link object service");
+ None
+ }
+ LinkObject::ServiceAction {
+ service_action_name,
+ service_action_id,
+ service_name,
+ service_ids,
+ } => {
+ println!("link object service action");
+ None
+ }
+ })
+}
+async fn list(
+ secd: &Secd,
+ cmd: ListObject,
+ filter_name: Option<String>,
+ filter_before: Option<i64>,
+ filter_after: Option<i64>,
+) -> Result<Option<String>> {
+ Ok(match cmd {
+ ListObject::ApiKey => {
+ println!("list object api key");
+ None
+ }
+ ListObject::Group => {
+ println!("list object group");
+ None
+ }
+ ListObject::Identity => {
+ println!("list object identity");
+ None
+ }
+ ListObject::Permission => {
+ println!("list object permission");
+ None
+ }
+ ListObject::Role => {
+ println!("list object role");
+ None
+ }
+ ListObject::Service => {
+ println!("list object service");
+ None
+ }
+ ListObject::ServiceAction => {
+ println!("list object service action");
+ None
+ }
+ ListObject::Session => {
+ println!("list object session");
+ None
+ }
+ ListObject::Validation => {
+ println!("list object valiation");
+ None
+ }
+ })
+}
diff --git a/crates/iam/src/util.rs b/crates/iam/src/util.rs
new file mode 100644
index 0000000..01ce851
--- /dev/null
+++ b/crates/iam/src/util.rs
@@ -0,0 +1,88 @@
+use crate::{
+ api::{CliError, Config, ConfigProfile},
+ CONFIG_DIR_NAME, CONFIG_PROFILE_FILE, ISSUE_TRACKER_LOC,
+};
+use anyhow::{anyhow, Context};
+use colored::Colorize;
+use home::home_dir;
+use secd::Secd;
+use std::{
+ env::var,
+ error::Error,
+ fs::{self, File},
+ io::{self, Read},
+ path::PathBuf,
+ result,
+ str::FromStr,
+};
+use thiserror;
+
+pub type Result<T> = anyhow::Result<T>;
+
+macro_rules! err {
+ ($($tt:tt)*) => { Err(Box::<dyn Error>::from(format!($($tt)*))) }
+}
+pub(crate) use err;
+
+#[derive(Debug, thiserror::Error)]
+pub enum InternalError {
+ #[error(
+ "Cannot read {0} profile from {1}. Initialize a default iam store with `iam admin init`"
+ )]
+ CannotReadProfile(String, String),
+}
+
+pub fn get_config_dir() -> PathBuf {
+ let xdg_dir = var("XDG_CONFIG_HOME").map(|s| PathBuf::from_str(&s).unwrap());
+ let mut home_dir = home_dir().expect(&format!(
+ "Could not find home directory. This should not be possible, please file a bug at {}",
+ ISSUE_TRACKER_LOC
+ ));
+
+ match xdg_dir {
+ Ok(mut d) => {
+ d.push(format!(".{}", CONFIG_DIR_NAME));
+ d
+ }
+ Err(_) => {
+ home_dir.push(".config");
+ home_dir.push(CONFIG_DIR_NAME);
+ home_dir
+ }
+ }
+}
+
+pub fn get_config_profile() -> PathBuf {
+ let mut config_dir = get_config_dir();
+ config_dir.push(CONFIG_PROFILE_FILE);
+ config_dir
+}
+
+pub fn read_config(profile_name: Option<String>) -> Result<ConfigProfile> {
+ let profile_path = get_config_profile();
+ let profile_name = profile_name.unwrap_or("default".into());
+
+ let bytes = fs::read(profile_path.clone())?;
+ let config: Config = toml::from_slice(&bytes)?;
+
+ let mut cfg = config
+ .profile
+ .into_iter()
+ .filter(|p| p.name == profile_name)
+ .last()
+ .ok_or(anyhow!(
+ "cannot read configuration file when calling read_config"
+ ))?;
+
+ if let Some(path) = cfg.email_template_login {
+ let buf = fs::read_to_string(path)?;
+ cfg.email_template_login = Some(buf);
+ }
+
+ if let Some(path) = cfg.email_template_signup {
+ let buf = fs::read_to_string(path)?;
+ cfg.email_template_signup = Some(buf);
+ }
+
+ Ok(cfg)
+}
diff --git a/crates/secd/Cargo.toml b/crates/secd/Cargo.toml
new file mode 100644
index 0000000..7e80277
--- /dev/null
+++ b/crates/secd/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "secd"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+async-std = { version = "1.12.0", features = [ "attributes" ] }
+async-trait = "0.1"
+base64 = "0.13.1"
+derive_more = "0.99"
+email_address = "0.2"
+lazy_static = "1.4"
+log = "0.4"
+openssl = "0.10.42"
+rand = "0.8"
+serde = "1"
+serde_json = { version = "1.0", features = ["raw_value"] }
+strum = "0.24.1"
+strum_macros = "0.24"
+sqlx = { version = "0.6", features = [ "runtime-async-std-native-tls", "postgres", "uuid", "sqlite", "time" ] }
+time = { version = "0.3", features = [ "serde" ] }
+thiserror = "1.0"
+uuid = { version = "1.2", features = ["v4", "serde"]} \ No newline at end of file
diff --git a/build.rs b/crates/secd/build.rs
index 3a8149e..3a8149e 100644
--- a/build.rs
+++ b/crates/secd/build.rs
diff --git a/crates/secd/src/client/email.rs b/crates/secd/src/client/email.rs
new file mode 100644
index 0000000..fc48702
--- /dev/null
+++ b/crates/secd/src/client/email.rs
@@ -0,0 +1,62 @@
+use std::{path::PathBuf, str::FromStr};
+
+use email_address::EmailAddress;
+
+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", validation_id))
+ .map_err(|_| EmailMessengerError::Unknown)?,
+ body,
+ )
+ .map_err(|_| EmailMessengerError::FailedToSendEmail)?;
+
+ Ok(())
+ }
+}
diff --git a/crates/secd/src/client/mod.rs b/crates/secd/src/client/mod.rs
new file mode 100644
index 0000000..3925657
--- /dev/null
+++ b/crates/secd/src/client/mod.rs
@@ -0,0 +1,209 @@
+pub mod email;
+pub mod sqldb;
+
+use std::collections::HashMap;
+
+use super::Identity;
+use crate::{EmailValidation, Session, SessionSecret};
+
+use lazy_static::lazy_static;
+use thiserror::Error;
+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),
+ EmailAlreadyExists,
+ CodeAppearsMoreThanOnce,
+ CodeDoesNotExist(String),
+ IdentityIdMustExistInvariant,
+ TooManyEmailValidations,
+ NoEmailValidationFound,
+ Unknown,
+}
+
+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 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";
+
+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"),
+ ),
+ ]
+ .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"),
+ ),
+ ]
+ .iter()
+ .cloned()
+ .collect();
+
+ let sqls: HashMap<&'static str, HashMap<&'static str, &'static str>> =
+ [(SQLITE, sqlite_sqls), (PGSQL, pg_sqls)]
+ .iter()
+ .cloned()
+ .collect();
+ sqls
+ };
+}
+
+#[async_trait::async_trait]
+pub trait Store {
+ async fn write_email(&self, identity_id: Uuid, 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
+ ) -> Result<Uuid, StoreError>;
+
+ async fn find_identity(
+ &self,
+ identity_id: Option<&Uuid>,
+ email: Option<&str>,
+ ) -> Result<Option<Identity>, StoreError>;
+ 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>;
+}
diff --git a/crates/secd/src/client/sqldb.rs b/crates/secd/src/client/sqldb.rs
new file mode 100644
index 0000000..6048c48
--- /dev/null
+++ b/crates/secd/src/client/sqldb.rs
@@ -0,0 +1,424 @@
+use std::sync::Arc;
+
+use super::{
+ EmailValidation, Identity, 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_SESSION, SQLITE, SQLS, WRITE_EMAIL, WRITE_EMAIL_VALIDATION,
+ WRITE_IDENTITY, WRITE_SESSION,
+};
+use crate::util;
+use log::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 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> 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, identity_id: Uuid, email_address: &str) -> Result<(), StoreError> {
+ let sqls = get_sqls(&self.sqls_root, WRITE_EMAIL);
+
+ let identity_id = self.read_identity_raw_id(&identity_id).await?;
+
+ let email_id: (i64,) = match sqlx::query_as(&sqls[0])
+ .bind(email_address)
+ .fetch_one(&self.pool)
+ .await
+ {
+ Ok(i) => i,
+ Err(sqlx::Error::RowNotFound) => sqlx::query_as::<_, (i64,)>(&sqls[1])
+ .bind(email_address)
+ .fetch_one(&self.pool)
+ .await
+ .map_err(util::log_err_sqlx)?,
+ Err(e) => return Err(StoreError::SqlxError(e)),
+ };
+
+ sqlx::query(&sqls[2])
+ .bind(identity_id)
+ .bind(email_id.0)
+ .bind(OffsetDateTime::now_utc())
+ .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::TooManyEmailValidations),
+ }
+ }
+
+ async fn write_email_validation(&self, ev: &EmailValidation) -> Result<Uuid, StoreError> {
+ let sqls = get_sqls(&self.sqls_root, WRITE_EMAIL_VALIDATION);
+
+ let identity_id = self
+ .read_identity_raw_id(
+ &ev.identity_id
+ .ok_or(StoreError::IdentityIdMustExistInvariant)?,
+ )
+ .await?;
+ let email_id = self.read_email_raw_id(&ev.email_address).await?;
+
+ let new_id = Uuid::new_v4();
+ sqlx::query(&sqls[0])
+ .bind(ev.id.unwrap_or(new_id))
+ .bind(identity_id)
+ .bind(email_id)
+ .bind(ev.attempts)
+ .bind(&ev.code)
+ .bind(ev.is_validated)
+ .bind(ev.created_at)
+ .bind(ev.expires_at)
+ .execute(&self.pool)
+ .await
+ .map_err(util::log_err_sqlx)?;
+
+ Ok(new_id)
+ }
+
+ async fn find_identity(
+ &self,
+ id: Option<&Uuid>,
+ email: Option<&str>,
+ ) -> Result<Option<Identity>, StoreError> {
+ let sqls = get_sqls(&self.sqls_root, FIND_IDENTITY);
+ Ok(
+ match sqlx::query_as::<_, Identity>(&sqls[0])
+ .bind(id)
+ .bind(email)
+ .fetch_one(&self.pool)
+ .await
+ {
+ Ok(i) => Some(i),
+ Err(sqlx::Error::RowNotFound) => None,
+ Err(e) => return Err(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> {
+ Ok(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)?)
+ }
+
+ 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(OffsetDateTime::now_utc())
+ .bind(session.expires_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)
+ }
+}
+
+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, identity_id: Uuid, email_address: &str) -> Result<(), StoreError> {
+ self.sql.write_email(identity_id, 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) -> Result<Uuid, StoreError> {
+ self.sql.write_email_validation(ev).await
+ }
+ async fn find_identity(
+ &self,
+ identity_id: Option<&Uuid>,
+ email: Option<&str>,
+ ) -> Result<Option<Identity>, StoreError> {
+ 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
+ }
+}
+
+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, identity_id: Uuid, email_address: &str) -> Result<(), StoreError> {
+ self.sql.write_email(identity_id, 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) -> Result<Uuid, StoreError> {
+ self.sql.write_email_validation(ev).await
+ }
+ async fn find_identity(
+ &self,
+ identity_id: Option<&Uuid>,
+ email: Option<&str>,
+ ) -> Result<Option<Identity>, StoreError> {
+ 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
+ }
+}
diff --git a/crates/secd/src/lib.rs b/crates/secd/src/lib.rs
new file mode 100644
index 0000000..9eb7f0e
--- /dev/null
+++ b/crates/secd/src/lib.rs
@@ -0,0 +1,409 @@
+mod client;
+mod util;
+
+use std::sync::Arc;
+
+use client::{
+ email,
+ sqldb::{PgClient, SqliteClient},
+ EmailMessenger, EmailMessengerError, Store, StoreError,
+};
+use derive_more::Display;
+use email_address::EmailAddress;
+use log::error;
+use rand::distributions::{Alphanumeric, DistString};
+use serde::{Deserialize, Serialize};
+use strum_macros::{EnumString, EnumVariantNames};
+use time::{Duration, OffsetDateTime};
+use uuid::Uuid;
+
+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_ATTEMPTS_MAX: i32 = 5;
+const VALIDATION_CODE_SIZE: usize = 6;
+
+#[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,
+ created_at: OffsetDateTime,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ data: Option<String>,
+}
+
+#[derive(sqlx::FromRow, Debug, Serialize)]
+pub struct Session {
+ #[sqlx(rename = "identity_public_id")]
+ identity_id: IdentityId,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[sqlx(default)]
+ secret: Option<SessionSecret>,
+ #[serde(with = "time::serde::timestamp")]
+ created_at: OffsetDateTime,
+ #[serde(with = "time::serde::timestamp")]
+ expires_at: OffsetDateTime,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ revoked_at: Option<OffsetDateTime>,
+}
+
+#[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,
+ attempts: i32,
+ code: String,
+ is_validated: bool,
+ created_at: OffsetDateTime,
+ expires_at: OffsetDateTime,
+ revoked_at: Option<OffsetDateTime>,
+}
+
+#[derive(Copy, Display, Clone, Debug)]
+pub enum OauthProvider {
+ Amazon,
+ Apple,
+ Dropbox,
+ Facebook,
+ Github,
+ Gitlab,
+ Google,
+ Instagram,
+ LinkedIn,
+ Microsoft,
+ Paypal,
+ Reddit,
+ Spotify,
+ Strava,
+ Stripe,
+ Twitch,
+ Twitter,
+ WeChat,
+}
+
+#[derive(Display, Debug, Serialize, Deserialize, EnumString, EnumVariantNames)]
+#[strum(ascii_case_insensitive)]
+pub enum AuthStore {
+ Sqlite,
+ Postgres,
+ MySql,
+ Mongo,
+ Dynamo,
+ Redis,
+}
+
+#[derive(Display, Debug, Serialize, Deserialize, EnumString, EnumVariantNames)]
+#[strum(ascii_case_insensitive)]
+pub enum AuthEmail {
+ LocalStub,
+ Ses,
+ Mailgun,
+ Sendgrid,
+}
+
+pub type IdentityId = Uuid;
+pub type SessionSecret = String;
+pub type SessionSecretHash = String;
+pub type ValidationRequestId = Uuid;
+pub type ValidationSecretCode = String;
+
+#[derive(Debug, derive_more::Display, thiserror::Error)]
+pub enum SecdError {
+ InvalidEmailAddress,
+ InvalidCode,
+ InitializationFailure(sqlx::Error),
+ IdentityIdShouldExistInvariant,
+ EmailSendError(#[from] EmailMessengerError),
+ EmailValidationRequestError,
+ EmailValidationExpiryOverflow,
+ SessionExpiryOverflow,
+ Unauthenticated,
+ Unknown,
+}
+
+pub struct Secd {
+ store: Arc<dyn Store + Send + Sync + 'static>,
+ email_messenger: Arc<dyn EmailMessenger + Send + Sync + 'static>,
+}
+
+impl Secd {
+ 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> {
+ let store = match auth_store {
+ AuthStore::Sqlite => {
+ SqliteClient::new(
+ sqlx::sqlite::SqlitePoolOptions::new()
+ .connect(conn_string.unwrap_or("sqlite::memory:".into()))
+ .await
+ .map_err(|e| SecdError::InitializationFailure(e))?,
+ )
+ .await
+ }
+ AuthStore::Postgres => {
+ PgClient::new(
+ sqlx::postgres::PgPoolOptions::new()
+ .connect(conn_string.expect("No postgres connection string provided."))
+ .await
+ .map_err(|e| SecdError::InitializationFailure(e))?,
+ )
+ .await
+ }
+ rest @ _ => {
+ error!(
+ "requested an AuthStore which has not yet been implemented: {:?}",
+ rest
+ );
+ unimplemented!()
+ }
+ };
+
+ let email_sender = match email_messenger {
+ // TODO: initialize email and SMS templates with secd
+ AuthEmail::LocalStub => email::LocalEmailStubber {
+ email_template_login,
+ email_template_signup,
+ },
+ _ => unimplemented!(),
+ };
+
+ Ok(Secd {
+ store,
+ email_messenger: Arc::new(email_sender),
+ })
+ }
+ /// create_validation_request
+ ///
+ /// Generate a request to validate the provided email.
+ pub async fn create_validation_request(
+ &self,
+ email: Option<&str>,
+ ) -> Result<ValidationRequestId, SecdError> {
+ let now = OffsetDateTime::now_utc();
+
+ let email = match email {
+ Some(ea) => {
+ if EmailAddress::is_valid(ea) {
+ ea
+ } else {
+ return Err(SecdError::InvalidEmailAddress);
+ }
+ }
+ None => return Err(SecdError::InvalidEmailAddress),
+ };
+
+ let mut ev = EmailValidation {
+ id: None,
+ identity_id: None,
+ email_address: email.to_string(),
+ attempts: 0,
+ code: Alphanumeric
+ .sample_string(&mut rand::thread_rng(), VALIDATION_CODE_SIZE)
+ .to_lowercase(),
+ is_validated: false,
+ created_at: now,
+ expires_at: now
+ .checked_add(Duration::new(EMAIL_VALIDATION_DURATION, 0))
+ .ok_or(SecdError::EmailValidationExpiryOverflow)?,
+ revoked_at: None,
+ };
+
+ let (req_id, mail_type) = match self
+ .store
+ .find_identity(None, Some(email))
+ .await
+ .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?
+ {
+ 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::Unknown))?
+ };
+ (req_id, client::EmailType::Login)
+ }
+ None => {
+ let identity = Identity {
+ id: Uuid::new_v4(),
+ created_at: OffsetDateTime::now_utc(),
+ data: None,
+ };
+ self.store
+ .write_identity(&identity)
+ .await
+ .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?;
+ self.store
+ .write_email(identity.id, email)
+ .await
+ .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?;
+
+ 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::Unknown))?
+ };
+
+ (req_id, client::EmailType::Signup)
+ }
+ };
+
+ self.email_messenger
+ .send_email(email, &req_id.to_string(), &ev.code, mail_type)
+ .await?;
+
+ Ok(req_id)
+ }
+ /// 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(
+ &self,
+ validation_request_id: ValidationRequestId,
+ code: ValidationSecretCode,
+ ) -> Result<Session, SecdError> {
+ let mut ev = self
+ .store
+ .find_email_validation(Some(&validation_request_id), Some(&code))
+ .await
+ .map_err(|e| util::log_err(e.into(), SecdError::EmailValidationExpiryOverflow))?;
+
+ if ev.is_validated
+ || ev.expires_at < OffsetDateTime::now_utc()
+ || ev.attempts >= VALIDATION_ATTEMPTS_MAX
+ {
+ return Err(SecdError::InvalidCode);
+ };
+
+ ev.is_validated = true;
+ ev.attempts += 1;
+ self.store
+ .write_email_validation(&ev)
+ .await
+ .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?;
+
+ // TODO: clear previous sessions if they fit the criteria
+ let now = OffsetDateTime::now_utc();
+ let s = Session {
+ identity_id: ev
+ .identity_id
+ .ok_or(SecdError::IdentityIdShouldExistInvariant)?,
+ secret: Some(Alphanumeric.sample_string(&mut rand::thread_rng(), SESSION_SIZE_BYTES)),
+ created_at: now,
+ expires_at: now
+ .checked_add(Duration::new(SESSION_DURATION, 0))
+ .ok_or(SecdError::SessionExpiryOverflow)?,
+ revoked_at: None,
+ };
+ self.store
+ .write_session(&s)
+ .await
+ .map_err(|e| util::log_err(e.into(), SecdError::Unknown))?;
+
+ Ok(s)
+ }
+ /// 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.expires_at > OffsetDateTime::now_utc()
+ || session.revoked_at > Some(OffsetDateTime::now_utc()) =>
+ {
+ Ok(Authorization { session })
+ }
+ Ok(_) => Err(SecdError::Unauthenticated),
+ Err(_e) => Err(SecdError::Unknown),
+ }
+ }
+ /// 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!()
+ }
+
+ // register service
+ // register service_action(service_id, action)
+ // list services
+ // list service actions
+
+ // 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)
+}
diff --git a/crates/secd/src/util/mod.rs b/crates/secd/src/util/mod.rs
new file mode 100644
index 0000000..da16901
--- /dev/null
+++ b/crates/secd/src/util/mod.rs
@@ -0,0 +1,21 @@
+use log::error;
+use rand::distributions::Alphanumeric;
+use rand::{thread_rng, Rng};
+
+use crate::SecdError;
+
+pub(crate) fn log_err(e: Box<dyn std::error::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()
+}
diff --git a/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql b/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql
new file mode 100644
index 0000000..7a1bf07
--- /dev/null
+++ b/crates/secd/store/pg/migrations/20221116062550_bootstrap.sql
@@ -0,0 +1,49 @@
+create extension if not exists pgcrypto;
+create extension if not exists citext;
+create schema if not exists auth;
+
+create table if not exists auth.identity (
+ identity_id bigserial primary key
+ , identity_public_id uuid
+ , data text
+ , created_at timestamptz not null
+ , unique(identity_public_id)
+);
+
+create table if not exists auth.email (
+ email_id bigserial primary key
+ , address text not null
+ , unique(address)
+);
+
+create table if not exists auth.identity_email (
+ identity_email_id bigserial primary key
+ , identity_id bigint not null references auth.identity(identity_id)
+ , email_id bigint not null references auth.email(email_id)
+ , created_at timestamptz not null
+ , deleted_at timestamptz
+);
+
+create table if not exists auth.email_validation (
+ email_validation_id bigserial primary key
+ , email_validation_public_id uuid not null
+ , identity_email_id integer not null references auth.identity_email(identity_email_id)
+ , attempts integer not null
+ , code text
+ , is_validated boolean not null default false
+ , created_at timestamptz not null
+ , expires_at timestamptz
+ , revoked_at timestamptz
+ , unique(email_validation_public_id)
+);
+
+create table if not exists auth.session (
+ session_id bigserial primary key
+ , identity_id bigint not null references auth.identity(identity_id)
+ , secret_hash bytea not null
+ , created_at timestamptz not null
+ , touched_at timestamptz not null
+ , expires_at timestamptz
+ , revoked_at timestamptz
+ , unique(secret_hash)
+);
diff --git a/crates/secd/store/pg/sql/find_email_validation.sql b/crates/secd/store/pg/sql/find_email_validation.sql
new file mode 100644
index 0000000..d16d8e7
--- /dev/null
+++ b/crates/secd/store/pg/sql/find_email_validation.sql
@@ -0,0 +1,17 @@
+select
+ ev.email_validation_public_id
+ , i.identity_public_id
+ , e.address
+ , ev.attempts
+ , ev.code
+ , ev.is_validated
+ , ev.created_at
+ , ev.expires_at
+ , ev.revoked_at
+from auth.email_validation ev
+join auth.identity_email ie using (identity_email_id)
+join auth.email e using (email_id)
+join auth.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
new file mode 100644
index 0000000..3a86a83
--- /dev/null
+++ b/crates/secd/store/pg/sql/find_identity.sql
@@ -0,0 +1,9 @@
+select
+ identity_public_id,
+ data,
+ i.created_at
+from auth.identity i
+join auth.identity_email ie using (identity_id)
+join auth.email e using (email_id)
+where (($1 is null) or (i.identity_public_id = $1))
+and (($2 is null) or (e.address = $2))
diff --git a/crates/secd/store/pg/sql/find_identity_by_code.sql b/crates/secd/store/pg/sql/find_identity_by_code.sql
new file mode 100644
index 0000000..9df6614
--- /dev/null
+++ b/crates/secd/store/pg/sql/find_identity_by_code.sql
@@ -0,0 +1,11 @@
+select identity_email_id
+from auth.email_validation
+where email_validation_public_id = $1::uuid
+--
+select
+ identity_public_id
+ , data
+ , i.created_at
+from auth.identity i
+left join auth.identity_email ie using (identity_id)
+where ie.identity_email_id = $1;
diff --git a/crates/secd/store/pg/sql/read_email_raw_id.sql b/crates/secd/store/pg/sql/read_email_raw_id.sql
new file mode 100644
index 0000000..f62331c
--- /dev/null
+++ b/crates/secd/store/pg/sql/read_email_raw_id.sql
@@ -0,0 +1 @@
+select email_id from auth.email where address = $1
diff --git a/crates/secd/store/pg/sql/read_identity.sql b/crates/secd/store/pg/sql/read_identity.sql
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crates/secd/store/pg/sql/read_identity.sql
diff --git a/crates/secd/store/pg/sql/read_identity_raw_id.sql b/crates/secd/store/pg/sql/read_identity_raw_id.sql
new file mode 100644
index 0000000..d550cc0
--- /dev/null
+++ b/crates/secd/store/pg/sql/read_identity_raw_id.sql
@@ -0,0 +1,2 @@
+select identity_id from auth.identity where identity_public_id = $1;
+--
diff --git a/crates/secd/store/pg/sql/read_session.sql b/crates/secd/store/pg/sql/read_session.sql
new file mode 100644
index 0000000..febc1ab
--- /dev/null
+++ b/crates/secd/store/pg/sql/read_session.sql
@@ -0,0 +1,8 @@
+select
+ i.identity_public_id
+ , s.created_at
+ , s.expires_at
+ , s.revoked_at
+from auth.session s
+join auth.identity i using (identity_id)
+where secret_hash = $1;
diff --git a/crates/secd/store/pg/sql/write_email.sql b/crates/secd/store/pg/sql/write_email.sql
new file mode 100644
index 0000000..75fc494
--- /dev/null
+++ b/crates/secd/store/pg/sql/write_email.sql
@@ -0,0 +1,11 @@
+insert into auth.email (
+ address
+) values (
+ $1
+) on conflict (address) do nothing
+returning email_id;
+--
+select email_id from auth.email where address = $1;
+--
+insert into auth.identity_email (identity_id, email_id, created_at) values ($1, $2, $3);
+--
diff --git a/crates/secd/store/pg/sql/write_email_validation.sql b/crates/secd/store/pg/sql/write_email_validation.sql
new file mode 100644
index 0000000..98fc60e
--- /dev/null
+++ b/crates/secd/store/pg/sql/write_email_validation.sql
@@ -0,0 +1,27 @@
+insert into auth.email_validation
+ (
+ email_validation_public_id
+ , identity_email_id
+ , attempts
+ , code
+ , is_validated
+ , created_at
+ , expires_at
+ )
+values (
+ $1
+ , (
+ select identity_email_id
+ from auth.identity_email
+ where identity_id = $2
+ and email_id = $3
+ )
+ , $4
+ , $5
+ , $6
+ , $7
+ , $8
+) on conflict (email_validation_public_id) do update
+ set attempts = excluded.attempts
+ , is_validated = excluded.is_validated
+ , expires_at = excluded.expires_at;
diff --git a/crates/secd/store/pg/sql/write_identity.sql b/crates/secd/store/pg/sql/write_identity.sql
new file mode 100644
index 0000000..eed1710
--- /dev/null
+++ b/crates/secd/store/pg/sql/write_identity.sql
@@ -0,0 +1,9 @@
+insert into auth.identity (
+ identity_public_id,
+ data,
+ created_at
+) values (
+ $1,
+ $2,
+ $3
+);
diff --git a/crates/secd/store/pg/sql/write_session.sql b/crates/secd/store/pg/sql/write_session.sql
new file mode 100644
index 0000000..cd5892b
--- /dev/null
+++ b/crates/secd/store/pg/sql/write_session.sql
@@ -0,0 +1,18 @@
+insert into auth.session (
+ identity_id
+ , secret_hash
+ , created_at
+ , touched_at
+ , expires_at
+ , revoked_at
+) values (
+ (select identity_id from auth.identity where identity_public_id = $1)
+ , $2
+ , $3
+ , $4
+ , $5
+ , $6
+) on conflict (secret_hash) do update
+ set touched_at = excluded.touched_at
+ , revoked_at = excluded.revoked_at;
+--
diff --git a/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql
new file mode 100644
index 0000000..aa95afc
--- /dev/null
+++ b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql
@@ -0,0 +1,45 @@
+create table if not exists identity (
+ identity_id integer primary key autoincrement
+ , identity_public_id uuid
+ , data text
+ , created_at timestamp not null
+ , unique(identity_public_id)
+);
+
+create table if not exists email (
+ email_id integer primary key autoincrement
+ , address text not null
+ , unique(address)
+);
+
+create table if not exists identity_email (
+ identity_email_id integer primary key autoincrement
+ , identity_id integer not null references identity(identity_id)
+ , email_id integer not null references email(email_id)
+ , created_at timestamp not null
+ , deleted_at timestamp
+);
+
+create table if not exists email_validation (
+ email_validation_id integer primary key autoincrement
+ , email_validation_public_id text not null -- uuid
+ , identity_email_id integer not null references identity_email(identity_email_id)
+ , attempts integer not null
+ , code text
+ , is_validated boolean not null
+ , created_at timestamp not null
+ , expires_at timestamp
+ , revoked_at timestamp
+ , unique(email_validation_public_id)
+);
+
+create table if not exists session (
+ session_id integer primary key autoincrement
+ , identity_id not null references identity(identity_id)
+ , secret_hash blob not null
+ , created_at timestamp not null
+ , touched_at timestamp not null
+ , expires_at timestamp
+ , revoked_at timestamp
+ , unique(secret_hash)
+);
diff --git a/crates/secd/store/sqlite/sql/find_email_validation.sql b/crates/secd/store/sqlite/sql/find_email_validation.sql
new file mode 100644
index 0000000..a34c149
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/find_email_validation.sql
@@ -0,0 +1,16 @@
+select
+ ev.email_validation_public_id
+ , i.identity_public_id
+ , e.address
+ , ev.attempts
+ , ev.code
+ , ev.is_validated
+ , ev.created_at
+ , ev.expires_at
+ , ev.revoked_at
+from email_validation ev
+join identity_email ie using (identity_email_id)
+join email e using (email_id)
+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
new file mode 100644
index 0000000..bd1654d
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/find_identity.sql
@@ -0,0 +1,9 @@
+select
+ identity_public_id,
+ data,
+ i.created_at
+from identity i
+join identity_email ie using (identity_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))
diff --git a/crates/secd/store/sqlite/sql/find_identity_by_code.sql b/crates/secd/store/sqlite/sql/find_identity_by_code.sql
new file mode 100644
index 0000000..e1a6050
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/find_identity_by_code.sql
@@ -0,0 +1,11 @@
+select identity_email_id
+from auth.email_validation
+where email_validation_public_id = ?1;
+--
+select
+ identity_public_id
+ , data
+ , i.created_at
+from auth.identity i
+left join auth.identity_email ie using (identity_id)
+where ie.identity_email_id = ?1;
diff --git a/crates/secd/store/sqlite/sql/read_email_raw_id.sql b/crates/secd/store/sqlite/sql/read_email_raw_id.sql
new file mode 100644
index 0000000..0bbafad
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/read_email_raw_id.sql
@@ -0,0 +1 @@
+select email_id from email where address = ?
diff --git a/crates/secd/store/sqlite/sql/read_identity.sql b/crates/secd/store/sqlite/sql/read_identity.sql
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/read_identity.sql
diff --git a/crates/secd/store/sqlite/sql/read_identity_raw_id.sql b/crates/secd/store/sqlite/sql/read_identity_raw_id.sql
new file mode 100644
index 0000000..552c570
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/read_identity_raw_id.sql
@@ -0,0 +1,2 @@
+select identity_id from identity where identity_public_id = ?;
+--
diff --git a/crates/secd/store/sqlite/sql/read_session.sql b/crates/secd/store/sqlite/sql/read_session.sql
new file mode 100644
index 0000000..4daa352
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/read_session.sql
@@ -0,0 +1,8 @@
+select
+ i.identity_public_id
+ , s.created_at
+ , s.expires_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/write_email.sql b/crates/secd/store/sqlite/sql/write_email.sql
new file mode 100644
index 0000000..c127d9c
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/write_email.sql
@@ -0,0 +1,11 @@
+insert into email (
+ address
+) values (
+ ?1
+) on conflict (address) do nothing
+returning email_id;
+--
+select email_id from email where email = ?1;
+--
+insert into identity_email (identity_id, email_id, created_at) values (?1, ?2, ?3);
+--
diff --git a/crates/secd/store/sqlite/sql/write_email_validation.sql b/crates/secd/store/sqlite/sql/write_email_validation.sql
new file mode 100644
index 0000000..37b13e1
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/write_email_validation.sql
@@ -0,0 +1,27 @@
+insert into email_validation
+ (
+ email_validation_public_id
+ , identity_email_id
+ , attempts
+ , code
+ , is_validated
+ , created_at
+ , expires_at
+ )
+values (
+ ?1
+ , (
+ select identity_email_id
+ from identity_email
+ where identity_id = ?2
+ and email_id = ?3
+ )
+ , ?4
+ , ?5
+ , ?6
+ , ?7
+ , ?8
+) on conflict (email_validation_public_id) do update
+ set attempts = excluded.attempts
+ , is_validated = excluded.is_validated
+ , expires_at = excluded.expires_at;
diff --git a/crates/secd/store/sqlite/sql/write_identity.sql b/crates/secd/store/sqlite/sql/write_identity.sql
new file mode 100644
index 0000000..ff54468
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/write_identity.sql
@@ -0,0 +1 @@
+insert into identity (identity_public_id, data, created_at) values (?1, ?2, ?3);
diff --git a/crates/secd/store/sqlite/sql/write_session.sql b/crates/secd/store/sqlite/sql/write_session.sql
new file mode 100644
index 0000000..3c26986
--- /dev/null
+++ b/crates/secd/store/sqlite/sql/write_session.sql
@@ -0,0 +1,18 @@
+insert into session (
+ identity_id
+ , secret_hash
+ , created_at
+ , touched_at
+ , expires_at
+ , revoked_at
+) values (
+ (select identity_id from identity where identity_public_id = ?1)
+ , ?2
+ , ?3
+ , ?4
+ , ?5
+ , ?6
+) on conflict (secret_hash) do update
+ set touched_at = excluded.touched_at
+ , revoked_at = excluded.revoked_at;
+--