From bea517ebd03a02c8545df2e04fbdab5122931bb2 Mon Sep 17 00:00:00 2001 From: Anton Bilous Date: Sat, 1 Feb 2025 11:33:57 +0200 Subject: [PATCH] Authentication service --- src/Cargo.lock | 54 ++++++ src/app/authentication/Cargo.toml | 14 ++ src/app/authentication/src/lib.rs | 4 + src/app/authentication/src/repository.rs | 86 +++++++++ src/app/authentication/src/service.rs | 212 +++++++++++++++++++++++ src/database/src/port.rs | 31 +++- src/database/src/port/base.rs | 26 +-- src/database/src/port/package.rs | 51 +++--- src/database/src/port/user.rs | 40 +++-- 9 files changed, 462 insertions(+), 56 deletions(-) create mode 100644 src/app/authentication/Cargo.toml create mode 100644 src/app/authentication/src/lib.rs create mode 100644 src/app/authentication/src/repository.rs create mode 100644 src/app/authentication/src/service.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index 9a38ac1..679dee7 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -57,6 +57,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "aliasable" version = "0.1.3" @@ -1420,7 +1429,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a989bd2fd12136080f7825ff410d9239ce84a2a639487fc9d924ee42e2fb84f" dependencies = [ "compact_str", + "garde_derive", + "once_cell", + "regex", "smallvec", + "url", +] + +[[package]] +name = "garde_derive" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7f0545bbbba0a37d4d445890fa5759814e0716f02417b39f6fab292193df68" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.96", ] [[package]] @@ -3213,6 +3238,35 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "renderdoc-sys" version = "1.1.0" diff --git a/src/app/authentication/Cargo.toml b/src/app/authentication/Cargo.toml new file mode 100644 index 0000000..3fafecd --- /dev/null +++ b/src/app/authentication/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "authentication" +version = "0.1.0" +edition = "2024" + +[dependencies] +argon2 = { version = "0.5.3", features = ["std"] } + +thiserror = "2.0.11" +garde = { version = "0.22.0", features = ["email", "url", "derive"] } +derive_more = { version = "1.0.0", features = ["deref", "deref_mut", "into"] } + +[dependencies.database] +path = "../../database" diff --git a/src/app/authentication/src/lib.rs b/src/app/authentication/src/lib.rs new file mode 100644 index 0000000..1a8b0c9 --- /dev/null +++ b/src/app/authentication/src/lib.rs @@ -0,0 +1,4 @@ +// use database::port::user; + +mod repository; +mod service; diff --git a/src/app/authentication/src/repository.rs b/src/app/authentication/src/repository.rs new file mode 100644 index 0000000..9cd74ea --- /dev/null +++ b/src/app/authentication/src/repository.rs @@ -0,0 +1,86 @@ +pub use database::port::user::*; + +use derive_more::{Deref, DerefMut}; + +#[derive(Deref, DerefMut)] +pub struct Authenticated(User); + +#[allow(async_fn_in_trait)] +pub trait AuthenticationRepository { + async fn get_user(&self, get: Get) -> Result>; + async fn create_user(&self, new: New) -> Result; + async fn start_session(&self, user: User) -> Result; +} + + +pub enum Get { + Name(Name), + Email(Email), +} + +impl From for Unique { + fn from(value: Get) -> Self { + match value { + Get::Name(s) => Self::Name(s), + Get::Email(s) => Self::Email(s), + } + } +} + +// Adapter + +use database::connect::Connect; +use std::marker::PhantomData; + +pub struct AuthenticationAdapter +where + D: Connect, + UR: UserRepository, +{ + driver: D, + _user_repository: PhantomData, +} + +impl AuthenticationAdapter +where + D: Connect, + UR: UserRepository, +{ + pub const fn new(driver: D) -> Self { + Self { + driver, + _user_repository: PhantomData, + } + } +} + +impl AuthenticationRepository for AuthenticationAdapter +where + D: Connect, + UR: UserRepository, +{ + async fn get_user(&self, get: Get) -> Result> { + let c = self.driver.open_connection().await?; + let user = UR::read(&c, get.into()).await?; + D::close_connection(c).await?; + + Ok(user) + } + + async fn create_user(&self, new: New) -> Result { + let mut c = self.driver.open_connection().await?; + let user = UR::create(&mut c, new).await?; + D::close_connection(c).await?; + + Ok(user) + } + + async fn start_session(&self, mut user: User) -> Result { + let mut c = self.driver.open_connection().await?; + UR::update(&mut c, &mut user, Field::LastUsed(Some(Utc::now()))).await?; + D::close_connection(c).await?; + + Ok(Authenticated(user)) + } +} + diff --git a/src/app/authentication/src/service.rs b/src/app/authentication/src/service.rs new file mode 100644 index 0000000..0e7fa60 --- /dev/null +++ b/src/app/authentication/src/service.rs @@ -0,0 +1,212 @@ +use crate::repository::{Authenticated, AuthenticationRepository}; +use database::port::user; + +use derive_more::{Deref, Into}; +use garde::Validate; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + // Login + #[error("login not found")] + LoginNotFound, + #[error("incorrect password")] + IncorrectPassword, + // Register + #[error("username is taken")] + NameExists, + #[error("email is already in use")] + EmailExists, + // Shared + #[error("invalid password: {0}")] + InvalidPassword(Box), + #[error("data source error: {0}")] + Repository(Box), +} + +pub type Result = std::result::Result; + +#[derive(Clone, Deref, Into)] +pub struct Name(user::Name); + +impl TryFrom for Name { + type Error = Box; + + fn try_from(value: String) -> std::result::Result { + #[derive(Validate)] + #[garde(transparent)] + struct Username<'a>(#[garde(alphanumeric, length(chars, min = 2, max = 31))] &'a str); + + Username(&value).validate()?; + Ok(Self(user::Name::try_from(value)?)) + } +} + +#[derive(Clone, Deref, Into)] +pub struct Email(user::Email); +impl TryFrom for Email { + type Error = Box; + + fn try_from(value: String) -> std::result::Result { + #[derive(Validate)] + #[garde(transparent)] + pub struct Email<'a>(#[garde(email, length(chars, max = 255))] &'a str); + + Email(&value).validate()?; + Ok(Self(user::Email::try_from(value)?)) + } +} + +// #[derive(Validate, Deref, From, Into)] +// #[garde(transparent)] +// pub struct Password(#[garde(length(chars, min = 8))] pub String); + +pub enum LoginBy { + Name(Name), + Email(Email), +} + +pub struct LoginData { + login: LoginBy, + password: String, +} + +pub struct RegisterData { + pub name: Name, + pub email: Email, + pub password: String, +} + +pub trait AuthenticationContract { + async fn name_available(&self, name: Name) -> Result; + async fn email_available(&self, email: Email) -> Result; + + async fn login(&mut self, data: LoginData) -> Result; + async fn register(&mut self, data: RegisterData) -> Result; +} + +// Service + +use crate::repository::Get; + +use argon2::{ + Argon2, + password_hash::{ + self, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng, + }, +}; + +impl From for Error { + fn from(error: password_hash::Error) -> Self { + match error { + password_hash::Error::Password => Self::IncorrectPassword, + _ => Self::InvalidPassword(error.into()), + } + } +} + +pub struct AuthenticationService +where + R: AuthenticationRepository, +{ + repository: R, +} + +impl AuthenticationService +where + R: AuthenticationRepository, +{ + pub const fn new(repository: R) -> Self { + Self { repository } + } +} + +impl AuthenticationContract for AuthenticationService +where + R: AuthenticationRepository, +{ + async fn name_available(&self, name: Name) -> Result { + if self + .repository + .get_user(Get::Name(name.0)) + .await + .map_err(Error::Repository)? + .is_some() + { + return Err(Error::NameExists); + }; + Ok(()) + } + async fn email_available(&self, email: Email) -> Result { + if self + .repository + .get_user(Get::Email(email.0)) + .await + .map_err(Error::Repository)? + .is_some() + { + return Err(Error::EmailExists); + }; + Ok(()) + } + + async fn login(&mut self, data: LoginData) -> Result { + if data.password.chars().count() < 8 { + return Err(Error::InvalidPassword( + "password must be longer than 8 characters".into(), + )); + } + + let user = match data.login { + LoginBy::Name(name) => self.repository.get_user(Get::Name(name.0)), + LoginBy::Email(email) => self.repository.get_user(Get::Email(email.0)), + } + .await + .map_err(Error::Repository)? + .ok_or(Error::LoginNotFound)?; + + Argon2::default().verify_password( + data.password.as_bytes(), + &PasswordHash::new(user.password())?, + )?; + + self.repository + .start_session(user) + .await + .map_err(Error::Repository) + } + + async fn register(&mut self, data: RegisterData) -> Result { + if data.password.chars().count() < 8 { + return Err(Error::InvalidPassword( + "password must be longer than 8 characters".into(), + )); + } + + self.name_available(data.name.clone()).await?; + self.email_available(data.email.clone()).await?; + + // Get PHC string ($argon2id$v=19$...) + let password = Argon2::default() + .hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng))? + .to_string() + .try_into() + .map_err(|e| Error::InvalidPassword(Box::from(e)))?; + + let user = self + .repository + .create_user(user::New { + name: data.name.0, + email: data.email.0, + password, + last_used: None, + }) + .await + .map_err(Error::Repository)?; + + self.repository + .start_session(user) + .await + .map_err(Error::Repository) + } +} + diff --git a/src/database/src/port.rs b/src/database/src/port.rs index 89c114e..ad0988d 100644 --- a/src/database/src/port.rs +++ b/src/database/src/port.rs @@ -1,4 +1,4 @@ -pub type Result = std::result::Result>; +pub type Result> = std::result::Result; #[allow(async_fn_in_trait)] pub trait CRUD { @@ -17,7 +17,34 @@ pub trait CRUD { async fn delete(connection: &mut C, data: Self::Unique) -> Result; } -const TOO_LONG: &str = "too long"; +trait CharLength { + fn length(&self) -> usize; +} +impl CharLength for String { + fn length(&self) -> usize { + self.chars().count() + } +} +impl CharLength for Option { + fn length(&self) -> usize { + self.as_ref().map_or(0, CharLength::length) + } +} + +trait MaxLength { + type Inner: CharLength; + const MAX_LENGTH: usize; + + fn validate(value: &Self::Inner) -> Result<(), &'static str> { + if value.length() > Self::MAX_LENGTH { + Err("too long") + } else { + Ok(()) + } + } +} + +// const TOO_LONG: &str = "too long"; pub mod base; pub mod package; diff --git a/src/database/src/port/base.rs b/src/database/src/port/base.rs index bdc18ab..e228966 100644 --- a/src/database/src/port/base.rs +++ b/src/database/src/port/base.rs @@ -1,3 +1,4 @@ +use super::MaxLength; pub use super::{CRUD, Result}; pub use chrono::{DateTime, Utc}; @@ -14,29 +15,30 @@ pub trait BaseRepository: #[derive(Clone, Deref, Into)] pub struct Name(String); +impl MaxLength for Name { + type Inner = String; + const MAX_LENGTH: usize = 127; +} impl TryFrom for Name { type Error = &'static str; - fn try_from(value: String) -> std::result::Result { - if value.chars().count() > 127 { - Err(super::TOO_LONG) - } else { - Ok(Self(value)) - } + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(value)) } } #[derive(Clone, Deref, Into)] pub struct Description(Option); +impl MaxLength for Description { + type Inner = Option; + const MAX_LENGTH: usize = 510; +} impl TryFrom> for Description { type Error = &'static str; - fn try_from(value: Option) -> std::result::Result { - if let Some(x) = &value { - if x.chars().count() > 510 { - return Err(super::TOO_LONG); - } - } + fn try_from(value: Option) -> Result { + Self::validate(&value)?; Ok(Self(value)) } } diff --git a/src/database/src/port/package.rs b/src/database/src/port/package.rs index f37d852..d4f6859 100644 --- a/src/database/src/port/package.rs +++ b/src/database/src/port/package.rs @@ -1,3 +1,4 @@ +use super::MaxLength; pub use super::{CRUD, Result, base::Base}; pub use chrono::{DateTime, Utc}; @@ -11,58 +12,60 @@ pub trait PackageRepository: #[derive(Clone, Deref, Into)] pub struct Name(String); +impl MaxLength for Name { + type Inner = String; + const MAX_LENGTH: usize = 127; +} impl TryFrom for Name { type Error = &'static str; - fn try_from(value: String) -> std::result::Result { - if value.chars().count() > 127 { - Err(super::TOO_LONG) - } else { - Ok(Self(value)) - } + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(value)) } } #[derive(Clone, Deref, Into)] pub struct Version(String); +impl MaxLength for Version { + type Inner = String; + const MAX_LENGTH: usize = 127; +} impl TryFrom for Version { type Error = &'static str; - fn try_from(value: String) -> std::result::Result { - if value.chars().count() > 127 { - Err(super::TOO_LONG) - } else { - Ok(Self(value)) - } + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(value)) } } #[derive(Clone, Deref, Into)] pub struct Description(Option); +impl MaxLength for Description { + type Inner = Option; + const MAX_LENGTH: usize = 255; +} impl TryFrom> for Description { type Error = &'static str; - fn try_from(value: Option) -> std::result::Result { - if let Some(x) = &value { - if x.chars().count() > 255 { - return Err(super::TOO_LONG); - } - } + fn try_from(value: Option) -> Result { + Self::validate(&value)?; Ok(Self(value)) } } #[derive(Clone, Deref, Into)] pub struct URL(Option); +impl MaxLength for URL { + type Inner = Option; + const MAX_LENGTH: usize = 510; +} impl TryFrom> for URL { type Error = &'static str; - fn try_from(value: Option) -> std::result::Result { - if let Some(x) = &value { - if x.chars().count() > 510 { - return Err(super::TOO_LONG); - } - } + fn try_from(value: Option) -> Result { + Self::validate(&value)?; Ok(Self(value)) } } diff --git a/src/database/src/port/user.rs b/src/database/src/port/user.rs index 64f096d..b4d4141 100644 --- a/src/database/src/port/user.rs +++ b/src/database/src/port/user.rs @@ -1,3 +1,4 @@ +use super::MaxLength; pub use super::{CRUD, Result}; pub use chrono::{DateTime, Utc}; @@ -11,43 +12,46 @@ pub trait UserRepository: #[derive(Clone, Deref, Into)] pub struct Name(String); +impl MaxLength for Name { + type Inner = String; + const MAX_LENGTH: usize = 31; +} impl TryFrom for Name { type Error = &'static str; - fn try_from(value: String) -> std::result::Result { - if value.chars().count() > 31 { - Err(super::TOO_LONG) - } else { - Ok(Self(value)) - } + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(value)) } } #[derive(Clone, Deref, Into)] pub struct Email(String); +impl MaxLength for Email { + type Inner = String; + const MAX_LENGTH: usize = 255; +} impl TryFrom for Email { type Error = &'static str; - fn try_from(value: String) -> std::result::Result { - if value.chars().count() > 255 { - Err(super::TOO_LONG) - } else { - Ok(Self(value)) - } + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(value)) } } #[derive(Clone, Deref, Into)] pub struct Password(String); +impl MaxLength for Password { + type Inner = String; + const MAX_LENGTH: usize = 255; +} impl TryFrom for Password { type Error = &'static str; - fn try_from(value: String) -> std::result::Result { - if value.chars().count() > 255 { - Err(super::TOO_LONG) - } else { - Ok(Self(value)) - } + fn try_from(value: String) -> Result { + Self::validate(&value)?; + Ok(Self(value)) } }