From 2c4eb2d311919ad9fb70738199ecf99bf20c9fce Mon Sep 17 00:00:00 2001 From: benj Date: Thu, 1 Dec 2022 10:30:34 -0800 Subject: - basic functionality with psql and sqlite - cli helper tool --- crates/secd/src/client/email.rs | 62 ++++++ crates/secd/src/client/mod.rs | 209 ++++++++++++++++++++ crates/secd/src/client/sqldb.rs | 424 ++++++++++++++++++++++++++++++++++++++++ crates/secd/src/lib.rs | 409 ++++++++++++++++++++++++++++++++++++++ crates/secd/src/util/mod.rs | 21 ++ 5 files changed, 1125 insertions(+) create mode 100644 crates/secd/src/client/email.rs create mode 100644 crates/secd/src/client/mod.rs create mode 100644 crates/secd/src/client/sqldb.rs create mode 100644 crates/secd/src/lib.rs create mode 100644 crates/secd/src/util/mod.rs (limited to 'crates/secd/src') 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, + pub(crate) email_template_signup: Option, +} + +#[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; + async fn write_email_validation( + &self, + ev: &EmailValidation, + // TODO: Make this write an EmailValidation + ) -> Result; + + async fn find_identity( + &self, + identity_id: Option<&Uuid>, + email: Option<&str>, + ) -> Result, StoreError>; + async fn find_identity_by_code(&self, code: &str) -> Result; + async fn write_identity(&self, i: &Identity) -> Result<(), StoreError>; + async fn read_identity(&self, identity_id: &Uuid) -> Result; + + async fn write_session(&self, session: &Session) -> Result<(), StoreError>; + async fn read_session(&self, secret: &SessionSecret) -> Result; +} 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 { + SQLS.get(root) + .unwrap() + .get(file) + .unwrap() + .split("--") + .map(|p| p.to_string()) + .collect() +} + +fn hash_secret(secret: &str) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + hasher.finish().to_vec() +} + +struct SqlClient +where + D: sqlx::Database, +{ + pool: sqlx::Pool, + sqls_root: String, +} + +impl SqlClient +where + D: sqlx::Database, + for<'c> >::Arguments: IntoArguments<'c, D>, + for<'c> i64: Decode<'c, D> + Type, + for<'c> &'c str: Decode<'c, D> + Type, + for<'c> &'c str: Encode<'c, D> + Type, + for<'c> usize: ColumnIndex<::Row>, + for<'c> Uuid: Decode<'c, D> + Type, + for<'c> Uuid: Encode<'c, D> + Type, + for<'c> &'c Pool: Executor<'c, Database = D>, +{ + async fn read_identity_raw_id(&self, id: &Uuid) -> Result { + 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 { + 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 Store for SqlClient +where + D: sqlx::Database, + for<'c> >::Arguments: IntoArguments<'c, D>, + for<'c> bool: Decode<'c, D> + Type, + for<'c> bool: Encode<'c, D> + Type, + for<'c> i64: Decode<'c, D> + Type, + for<'c> i64: Encode<'c, D> + Type, + for<'c> i32: Decode<'c, D> + Type, + for<'c> i32: Encode<'c, D> + Type, + for<'c> OffsetDateTime: Decode<'c, D> + Type, + for<'c> OffsetDateTime: Encode<'c, D> + Type, + for<'c> &'c str: ColumnIndex<::Row>, + for<'c> &'c str: Decode<'c, D> + Type, + for<'c> &'c str: Encode<'c, D> + Type, + for<'c> Option<&'c str>: Decode<'c, D> + Type, + for<'c> Option<&'c str>: Encode<'c, D> + Type, + for<'c> String: Decode<'c, D> + Type, + for<'c> String: Encode<'c, D> + Type, + for<'c> Option: Decode<'c, D> + Type, + for<'c> Option: Encode<'c, D> + Type, + for<'c> usize: ColumnIndex<::Row>, + for<'c> Uuid: Decode<'c, D> + Type, + for<'c> Uuid: Encode<'c, D> + Type, + for<'c> &'c [u8]: Encode<'c, D> + Type, + for<'c> Option<&'c Uuid>: Encode<'c, D> + Type, + for<'c> Option<&'c Vec>: Encode<'c, D> + Type, + for<'c> Option: Decode<'c, D> + Type, + for<'c> Option: Encode<'c, D> + Type, + for<'c> &'c Pool: 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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, +} + +impl PgClient { + pub async fn new(pool: sqlx::Pool) -> Arc { + 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 { + self.sql.find_email_validation(validation_id, code).await + } + async fn write_email_validation(&self, ev: &EmailValidation) -> Result { + self.sql.write_email_validation(ev).await + } + async fn find_identity( + &self, + identity_id: Option<&Uuid>, + email: Option<&str>, + ) -> Result, StoreError> { + self.sql.find_identity(identity_id, email).await + } + async fn find_identity_by_code(&self, code: &str) -> Result { + 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 { + 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 { + self.sql.read_session(secret).await + } +} + +pub struct SqliteClient { + sql: SqlClient, +} + +impl SqliteClient { + pub async fn new(pool: sqlx::Pool) -> Arc { + 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 { + self.sql.find_email_validation(validation_id, code).await + } + async fn write_email_validation(&self, ev: &EmailValidation) -> Result { + self.sql.write_email_validation(ev).await + } + async fn find_identity( + &self, + identity_id: Option<&Uuid>, + email: Option<&str>, + ) -> Result, StoreError> { + self.sql.find_identity(identity_id, email).await + } + async fn find_identity_by_code(&self, code: &str) -> Result { + 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 { + 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 { + 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, +} + +#[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, + #[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, +} + +#[derive(sqlx::FromRow, Debug)] +pub struct EmailValidation { + #[sqlx(rename = "email_validation_public_id")] + id: Option, + #[sqlx(rename = "identity_public_id")] + identity_id: Option, + #[sqlx(rename = "address")] + email_address: String, + attempts: i32, + code: String, + is_validated: bool, + created_at: OffsetDateTime, + expires_at: OffsetDateTime, + revoked_at: Option, +} + +#[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, + email_messenger: Arc, +} + +impl Secd { + pub async fn init( + auth_store: AuthStore, + conn_string: Option<&str>, + email_messenger: AuthEmail, + email_template_login: Option, + email_template_signup: Option, + ) -> Result { + 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 { + 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 { + 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 { + 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 { + 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, 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() +} -- cgit v1.2.3