diff options
Diffstat (limited to 'crates/secd/src/client')
| -rw-r--r-- | crates/secd/src/client/email/mod.rs | 186 | ||||
| -rw-r--r-- | crates/secd/src/client/store/mod.rs | 78 | ||||
| -rw-r--r-- | crates/secd/src/client/store/sql_db.rs | 132 |
3 files changed, 322 insertions, 74 deletions
diff --git a/crates/secd/src/client/email/mod.rs b/crates/secd/src/client/email/mod.rs index 915d18c..7c7b233 100644 --- a/crates/secd/src/client/email/mod.rs +++ b/crates/secd/src/client/email/mod.rs @@ -1,68 +1,172 @@ +use async_trait::async_trait; use email_address::EmailAddress; -use lettre::Transport; -use log::error; -use std::collections::HashMap; +use lettre::{ + message::{Mailbox, MultiPart}, + Transport, +}; +use log::{error, warn}; +use reqwest::StatusCode; +use sendgrid::v3::{Content, Email, Personalization}; +use std::sync::Arc; + +use crate::AddressValidationId; + +pub const DEFAULT_SIGNUP_EMAIL: &str = "This email was recently used to signup. Please use the following code to complete your validation: {{secd::validation_code}}. If you did not request this signup link, you can safely ignore this email."; +pub const DEFAULT_SIGNIN_EMAIL: &str = "An account with this email was recently used to signin. Please use the following code to complete your sign in process: {{secd::validation_code}}. If you did not request this signin link, you can safely ingore this email."; #[derive(Debug, thiserror::Error, derive_more::Display)] pub enum EmailMessengerError { FailedToSendEmail, + LibLettreError(#[from] lettre::error::Error), + SendgridError(#[from] sendgrid::SendgridError), } pub struct EmailValidationMessage { + pub from_address: Mailbox, + pub replyto_address: Mailbox, pub recipient: EmailAddress, pub subject: String, pub body: String, } -#[async_trait::async_trait] -pub(crate) trait EmailMessenger { - async fn send_email( - &self, - email_address: &EmailAddress, - template: &str, - template_vars: HashMap<&str, &str>, - ) -> Result<(), EmailMessengerError>; +#[async_trait] +pub(crate) trait EmailMessenger: Send + Sync { + fn get_type(&self) -> MessengerType; + fn get_api_key(&self) -> String; } -pub(crate) struct LocalMailer {} +pub enum MessengerType { + LocalMailer, + Sendgrid, +} -#[async_trait::async_trait] +pub(crate) struct LocalMailer {} +impl LocalMailer { + pub fn new() -> Arc<dyn EmailMessenger + Send + Sync + 'static> { + warn!("You are using the local mailer, which will not work in production!"); + Arc::new(LocalMailer {}) + } +} impl EmailMessenger for LocalMailer { - async fn send_email( - &self, - email_address: &EmailAddress, - template: &str, - template_vars: HashMap<&str, &str>, - ) -> Result<(), EmailMessengerError> { - todo!() + fn get_type(&self) -> MessengerType { + MessengerType::LocalMailer + } + fn get_api_key(&self) -> String { + panic!("unreachable since no API key is expected for LocalMailer"); + } +} +pub(crate) struct Sendgrid { + pub api_key: String, +} +impl Sendgrid { + pub fn new(api_key: String) -> Arc<dyn EmailMessenger + Send + Sync + 'static> { + Arc::new(Sendgrid { api_key }) + } +} +impl EmailMessenger for Sendgrid { + fn get_type(&self) -> MessengerType { + MessengerType::Sendgrid + } + fn get_api_key(&self) -> String { + self.api_key.clone() } } -#[async_trait::async_trait] +#[async_trait] pub(crate) trait Sendable { - async fn send(&self) -> Result<(), EmailMessengerError>; + async fn send(&self, messenge: Arc<dyn EmailMessenger>) -> Result<(), EmailMessengerError>; } -#[async_trait::async_trait] +#[async_trait] impl Sendable for EmailValidationMessage { - // TODO: We need to break this up as before, especially so we can feature - // gate unwanted things like Lettre... - async fn send(&self) -> Result<(), EmailMessengerError> { - // TODO: Get these things from the template... - let email = lettre::Message::builder() - .from("BranchControl <iam@branchcontrol.com>".parse().unwrap()) - .reply_to("BranchControl <iam@branchcontrol.com>".parse().unwrap()) - .to(self.recipient.to_string().parse().unwrap()) - .subject(self.subject.clone()) - .body(self.body.clone()) - .unwrap(); - - let mailer = lettre::SmtpTransport::unencrypted_localhost(); - - mailer.send(&email).map_err(|e| { - error!("failed to send email {:?}", e); - EmailMessengerError::FailedToSendEmail - })?; + async fn send(&self, messenger: Arc<dyn EmailMessenger>) -> Result<(), EmailMessengerError> { + match messenger.get_type() { + MessengerType::LocalMailer => { + let email = lettre::Message::builder() + .from(self.from_address.to_string().parse().unwrap()) + .reply_to(self.replyto_address.to_string().parse().unwrap()) + .to(self.recipient.to_string().parse().unwrap()) + .subject(self.subject.clone()) + .multipart(MultiPart::alternative_plain_html( + "".to_string(), + String::from(self.body.clone()), + ))?; + + let mailer = lettre::SmtpTransport::unencrypted_localhost(); + + mailer.send(&email).map_err(|e| { + error!("failed to send email {:?}", e); + EmailMessengerError::FailedToSendEmail + })?; + } + MessengerType::Sendgrid => { + let msg = sendgrid::v3::Message::new(Email::new(self.from_address.to_string())) + .set_subject(&self.subject) + .add_content( + Content::new() + .set_content_type("text/html") + .set_value(&self.body), + ) + .add_personalization(Personalization::new(Email::new( + self.recipient.to_string(), + ))); + + let sender = sendgrid::v3::Sender::new(messenger.get_api_key()); + let resp = sender.send(&msg).await?; + match resp.status() { + StatusCode::ACCEPTED => {} + _ => { + error!( + "sendgrid failed to send message with status: {}", + resp.status() + ) + } + } + } + }; + Ok(()) } } + +pub(crate) fn parse_email_template( + template: &str, + validation_id: AddressValidationId, + validation_secret: Option<String>, + validation_code: Option<String>, +) -> Result<String, EmailMessengerError> { + let mut t = template.clone().to_string(); + // We do not allow substutions for a variety of reasons, but mainly security ones. + // The only things we want to share are those which secd allows. In this case, that + // means we only send an email with static content as provided by the filter, except + // for the $$secd:request_id$$ and $$secd:request_code$$, either of which may be + // present in the email. + + t = t.replace("{{secd::validation_id}}", &validation_id.to_string()); + validation_secret.map(|secret| t = t.replace("{{secd::validation_secret}}", &secret)); + validation_code.map(|code| t = t.replace("{{secd::validation_code}}", &code)); + + Ok(t) +} + +#[cfg(test)] +mod test { + use uuid::Uuid; + + use super::*; + + #[test] + fn test_parse_and_substitue() { + let raw = "This is an email validation message. Navigate to https://www.secd.com/auth/{secd::validation_id}?s={secd::validation_secret} or use the code [{secd::validation_code}]"; + + let parsed = parse_email_template( + raw, + Uuid::parse_str("90f42ba9-ed4a-4f56-b371-df05634a1626").unwrap(), + Some("s3cr3t".into()), + Some("102030".into()), + ) + .unwrap(); + + assert_eq!(parsed, "This is an email validation message. Navigate to https://www.secd.com/auth/90f42ba9-ed4a-4f56-b371-df05634a1626?s=s3cr3t or use the code [102030]") + } +} diff --git a/crates/secd/src/client/store/mod.rs b/crates/secd/src/client/store/mod.rs index 8a076c4..7bf01d5 100644 --- a/crates/secd/src/client/store/mod.rs +++ b/crates/secd/src/client/store/mod.rs @@ -1,22 +1,28 @@ pub(crate) mod sql_db; +use async_trait::async_trait; use sqlx::{Postgres, Sqlite}; use std::sync::Arc; use uuid::Uuid; -use crate::{util, Address, AddressType, AddressValidation, Identity, IdentityId, Session}; +use crate::{ + util, Address, AddressType, AddressValidation, Credential, CredentialId, CredentialType, + Identity, IdentityId, Session, +}; use self::sql_db::SqlClient; #[derive(Debug, thiserror::Error, derive_more::Display)] pub enum StoreError { SqlClientError(#[from] sqlx::Error), + SerdeError(#[from] serde_json::Error), + ParseError(#[from] strum::ParseError), StoreValueCannotBeParsedInvariant, IdempotentCheckAlreadyExists, } -#[async_trait::async_trait(?Send)] -pub trait Store { +#[async_trait] +pub trait Store: Send + Sync { fn get_type(&self) -> StoreType; } @@ -25,7 +31,7 @@ pub enum StoreType { Sqlite { c: Arc<SqlClient<Sqlite>> }, } -#[async_trait::async_trait(?Send)] +#[async_trait] pub(crate) trait Storable<'a> { type Item; type Lens; @@ -64,7 +70,15 @@ pub(crate) struct SessionLens<'a> { } impl<'a> Lens for SessionLens<'a> {} -#[async_trait::async_trait(?Send)] +pub(crate) struct CredentialLens<'a> { + pub id: Option<CredentialId>, + pub identity_id: Option<IdentityId>, + pub t: Option<&'a CredentialType>, + pub restrict_by_key: Option<bool>, +} +impl<'a> Lens for CredentialLens<'a> {} + +#[async_trait] impl<'a> Storable<'a> for Address { type Item = Address; type Lens = AddressLens<'a>; @@ -93,7 +107,7 @@ impl<'a> Storable<'a> for Address { } } -#[async_trait::async_trait(?Send)] +#[async_trait] impl<'a> Storable<'a> for AddressValidation { type Item = AddressValidation; type Lens = AddressValidationLens<'a>; @@ -116,7 +130,7 @@ impl<'a> Storable<'a> for AddressValidation { } } -#[async_trait::async_trait(?Send)] +#[async_trait] impl<'a> Storable<'a> for Identity { type Item = Identity; type Lens = IdentityLens<'a>; @@ -158,7 +172,7 @@ impl<'a> Storable<'a> for Identity { } } -#[async_trait::async_trait(?Send)] +#[async_trait] impl<'a> Storable<'a> for Session { type Item = Session; type Lens = SessionLens<'a>; @@ -183,3 +197,51 @@ impl<'a> Storable<'a> for Session { }) } } + +#[async_trait] +impl<'a> Storable<'a> for Credential { + type Item = Credential; + type Lens = CredentialLens<'a>; + + async fn write(&self, store: Arc<dyn Store>) -> Result<(), StoreError> { + match store.get_type() { + StoreType::Postgres { c } => c.write_credential(self).await?, + StoreType::Sqlite { c } => c.write_credential(self).await?, + } + Ok(()) + } + + async fn find( + store: Arc<dyn Store>, + lens: &'a Self::Lens, + ) -> Result<Vec<Self::Item>, StoreError> { + Ok(match store.get_type() { + StoreType::Postgres { c } => { + c.find_credential( + lens.id, + lens.identity_id, + lens.t, + if let Some(true) = lens.restrict_by_key { + true + } else { + false + }, + ) + .await? + } + StoreType::Sqlite { c } => { + c.find_credential( + lens.id, + lens.identity_id, + lens.t, + if let Some(true) = lens.restrict_by_key { + true + } else { + false + }, + ) + .await? + } + }) + } +} diff --git a/crates/secd/src/client/store/sql_db.rs b/crates/secd/src/client/store/sql_db.rs index ecb13be..3e72fe8 100644 --- a/crates/secd/src/client/store/sql_db.rs +++ b/crates/secd/src/client/store/sql_db.rs @@ -1,27 +1,19 @@ -use std::{str::FromStr, sync::Arc}; - -use email_address::EmailAddress; -use serde_json::value::RawValue; -use sqlx::{ - database::HasArguments, types::Json, ColumnIndex, Database, Decode, Encode, Executor, - IntoArguments, Pool, Transaction, Type, -}; -use time::OffsetDateTime; -use uuid::Uuid; - +use super::{Store, StoreError, StoreType}; use crate::{ - Address, AddressType, AddressValidation, AddressValidationMethod, Identity, Session, - SessionToken, + Address, AddressType, AddressValidation, AddressValidationMethod, Credential, CredentialId, + CredentialType, Identity, IdentityId, Session, }; - +use email_address::EmailAddress; use lazy_static::lazy_static; +use sqlx::{ + database::HasArguments, ColumnIndex, Database, Decode, Encode, Executor, IntoArguments, Pool, + Transaction, Type, +}; use sqlx::{Postgres, Sqlite}; use std::collections::HashMap; - -use super::{ - AddressLens, AddressValidationLens, IdentityLens, SessionLens, Storable, Store, StoreError, - StoreType, -}; +use std::{str::FromStr, sync::Arc}; +use time::OffsetDateTime; +use uuid::Uuid; const SQLITE: &str = "sqlite"; const PGSQL: &str = "pgsql"; @@ -30,6 +22,8 @@ const WRITE_ADDRESS: &str = "write_address"; const FIND_ADDRESS: &str = "find_address"; const WRITE_ADDRESS_VALIDATION: &str = "write_address_validation"; const FIND_ADDRESS_VALIDATION: &str = "find_address_validation"; +const WRITE_CREDENTIAL: &str = "write_credential"; +const FIND_CREDENTIAL: &str = "find_credential"; const WRITE_IDENTITY: &str = "write_identity"; const FIND_IDENTITY: &str = "find_identity"; const WRITE_SESSION: &str = "write_session"; @@ -72,6 +66,14 @@ lazy_static! { FIND_SESSION, include_str!("../../../store/sqlite/sql/find_session.sql"), ), + ( + WRITE_CREDENTIAL, + include_str!("../../../store/sqlite/sql/write_credential.sql"), + ), + ( + FIND_CREDENTIAL, + include_str!("../../../store/sqlite/sql/find_credential.sql"), + ), ] .iter() .cloned() @@ -110,6 +112,14 @@ lazy_static! { FIND_SESSION, include_str!("../../../store/pg/sql/find_session.sql"), ), + ( + WRITE_CREDENTIAL, + include_str!("../../../store/pg/sql/write_credential.sql"), + ), + ( + FIND_CREDENTIAL, + include_str!("../../../store/pg/sql/find_credential.sql"), + ), ] .iter() .cloned() @@ -131,7 +141,7 @@ pub trait SqlxResultExt<T> { impl<T> SqlxResultExt<T> for Result<T, sqlx::Error> { fn extend_err(self) -> Result<T, StoreError> { if let Err(sqlx::Error::Database(dbe)) = &self { - if dbe.code() == Some("23505".into()) { + if dbe.code() == Some("23505".into()) || dbe.code() == Some("2067".into()) { return Err(StoreError::IdempotentCheckAlreadyExists); } } @@ -160,7 +170,7 @@ impl Store for PgClient { impl PgClient { pub async fn new(pool: sqlx::Pool<Postgres>) -> Arc<dyn Store + Send + Sync + 'static> { - sqlx::migrate!("store/pg/migrations") + sqlx::migrate!("store/pg/migrations", "secd") .run(&pool) .await .expect(ERR_MSG_MIGRATION_FAILED); @@ -187,7 +197,7 @@ impl Store for SqliteClient { impl SqliteClient { pub async fn new(pool: sqlx::Pool<Sqlite>) -> Arc<dyn Store + Send + Sync + 'static> { - sqlx::migrate!("store/sqlite/migrations") + sqlx::migrate!("store/sqlite/migrations", "secd") .run(&pool) .await .expect(ERR_MSG_MIGRATION_FAILED); @@ -410,8 +420,7 @@ where let sqls = get_sqls(&self.sqls_root, WRITE_IDENTITY); sqlx::query(&sqls[0]) .bind(i.id) - // TODO: validate this is actually Json somewhere way up the chain (when being deserialized) - .bind(i.metadata.clone().unwrap_or("{}".into())) + .bind(i.metadata.clone()) .bind(i.created_at) .bind(OffsetDateTime::now_utc()) .bind(i.deleted_at) @@ -449,7 +458,7 @@ where .extend_err()?; let mut res = vec![]; - for (id, metadata, created_at, updated_at, deleted_at) in rs.into_iter() { + for (id, metadata, created_at, _, deleted_at) in rs.into_iter() { res.push(Identity { id, address_validations: vec![], @@ -509,6 +518,79 @@ where } Ok(res) } + + pub async fn write_credential(&self, c: &Credential) -> Result<(), StoreError> { + let sqls = get_sqls(&self.sqls_root, WRITE_CREDENTIAL); + let partial_key = match &c.t { + crate::CredentialType::Passphrase { key, value: _ } => Some(key.clone()), + _ => None, + }; + + sqlx::query(&sqls[0]) + .bind(c.id) + .bind(c.identity_id) + .bind(partial_key) + .bind(c.t.to_string()) + .bind(serde_json::to_string(&c.t)?) + .bind(c.created_at) + .bind(c.revoked_at) + .bind(c.deleted_at) + .execute(&self.pool) + .await + .extend_err()?; + Ok(()) + } + pub async fn find_credential( + &self, + id: Option<Uuid>, + identity_id: Option<Uuid>, + t: Option<&CredentialType>, + restrict_by_key: bool, + ) -> Result<Vec<Credential>, StoreError> { + let sqls = get_sqls(&self.sqls_root, FIND_CREDENTIAL); + let key = restrict_by_key + .then(|| { + t.map(|i| match i { + CredentialType::Passphrase { key, value: _ } => key.clone(), + _ => todo!(), + }) + }) + .flatten(); + + let rs = sqlx::query_as::< + _, + ( + CredentialId, + IdentityId, + String, + OffsetDateTime, + Option<OffsetDateTime>, + Option<OffsetDateTime>, + ), + >(&sqls[0]) + .bind(id.as_ref()) + .bind(identity_id.as_ref()) + .bind(t.map(|i| i.to_string())) + .bind(key) + .fetch_all(&self.pool) + .await + .extend_err()?; + + let mut res = vec![]; + for (id, identity_id, data, created_at, revoked_at, deleted_at) in rs.into_iter() { + let t: CredentialType = serde_json::from_str(&data)?; + res.push(Credential { + id, + identity_id, + t, + created_at, + revoked_at, + deleted_at, + }) + } + + Ok(res) + } } fn get_sqls(root: &str, file: &str) -> Vec<String> { |
