aboutsummaryrefslogtreecommitdiff
path: root/crates/secd/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--crates/secd/src/lib.rs409
1 files changed, 409 insertions, 0 deletions
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)
+}