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) }