aboutsummaryrefslogtreecommitdiff
path: root/crates/secd/src
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/secd/src
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/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
5 files changed, 1125 insertions, 0 deletions
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()
+}