Skip to content

Commit

Permalink
feat(core): define auth boundaries in core logic
Browse files Browse the repository at this point in the history
  • Loading branch information
EstebanBorai committed Jun 8, 2024
1 parent 8856a3d commit cc3e2f9
Show file tree
Hide file tree
Showing 16 changed files with 248 additions and 85 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ chrono = "0.4.38"
dotenv = "0.15.0"
fake = "2.9.2"
jsonwebtoken = "9.3"
lazy_static = "1.4.0"
pxid = { version = "0.5", features = ["async-graphql"] }
rand = "0.8.5"
rust-argon2 = "2.1.0"
Expand Down
3 changes: 2 additions & 1 deletion crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ path = "src/lib.rs"

[dependencies]
chrono = { version = "0.4.26", features = ["serde"] }
lazy_static = "1.4.0"
rand = "0.8.5"
regex = "1.9.3"
serde = { version = "1.0.188", features = ["derive"] }
Expand All @@ -19,6 +18,8 @@ tracing = "0.1.37"

# Workspace Dependencies
async-trait = { workspace = true }
jsonwebtoken = { workspace = true }
lazy_static = { workspace = true }
sea-orm = { workspace = true, features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "mock" ] }
rust-argon2 = { workspace = true }
thiserror = { workspace = true }
Expand Down
11 changes: 11 additions & 0 deletions crates/core/src/auth/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use thiserror::Error;

pub type Result<T> = std::result::Result<T, AuthError>;

#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum AuthError {
#[error("Failed to sign token")]
SignTokenError,
#[error("Failed to encode JWT")]
JwtEncodeDecodeError(#[from] jsonwebtoken::errors::Error),
}
2 changes: 2 additions & 0 deletions crates/core/src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod error;
pub mod service;
110 changes: 110 additions & 0 deletions crates/core/src/auth/service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use std::fmt::Display;

use argon2::verify_encoded;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use pxid::Pxid;
use serde::{Deserialize, Serialize};

use super::error::{AuthError, Result};

const JWT_AUDIENCE: &str = "TownHall";
const TOKEN_DURATION: Duration = Duration::days(30);

/// JWT Token Abstaction
#[derive(Debug)]
pub struct Token {
pub(crate) raw: String,
pub(crate) claims: Claims,
}

impl Token {
/// Retrieves the token's user ID
pub fn user_id(&self) -> Pxid {
self.claims.uid
}

/// Retrieves the internal JWT String
pub fn token_string(&self) -> String {
self.raw.to_string()
}
}

impl Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.raw)
}
}

#[derive(Clone)]
pub struct AuthService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
validation: Validation,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub exp: usize,
pub uid: Pxid,
pub iat: usize,
}

impl AuthService {
pub fn new(jwt_secret: &str) -> Self {
let encoding_key = EncodingKey::from_secret(jwt_secret.as_bytes());
let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);

validation.set_audience(JWT_AUDIENCE.as_bytes());

Self {
encoding_key,
decoding_key,
validation,
}
}

pub fn sign_token(&self, uid: Pxid) -> Result<Token> {
let iat = Utc::now().timestamp() as usize;
let exp = Utc::now()
.checked_add_signed(TOKEN_DURATION)
.ok_or(AuthError::SignTokenError)?
.timestamp() as usize;
let claims = Claims { exp, iat, uid };
let jwt = encode(&Header::default(), &claims, &self.encoding_key)?;

Ok(Token { raw: jwt, claims })
}

pub fn verify_token(&self, token: &Token) -> Result<Claims> {
let token_data = decode::<Claims>(&token.raw, &self.decoding_key, &self.validation)?;

Ok(token_data.claims)
}

pub fn validate_password(&self, encoded: &str, raw: &str) -> bool {
let raw = raw.as_bytes();

verify_encoded(encoded, raw).unwrap()
}

pub fn parse_jwt(&self, jwt: &str) -> Result<Token> {
let claims = Self::decode_token(jwt, &self.decoding_key, &self.validation)?;

Ok(Token {
raw: jwt.to_string(),
claims,
})
}

pub(crate) fn decode_token(
token: &str,
decoding_key: &DecodingKey,
validation: &Validation,
) -> Result<Claims> {
let token_data = decode::<Claims>(token, decoding_key, validation)?;

Ok(token_data.claims)
}
}
8 changes: 8 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
pub mod auth;
pub mod image;
pub mod post;
pub mod shared;
pub mod user;

use lazy_static::lazy_static;
use pxid::Factory;

lazy_static! {
static ref PXID_GENERATOR: Factory = { Factory::new().expect("Failed to create Pxid factory") };
}
38 changes: 4 additions & 34 deletions crates/core/src/user/model/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,28 @@ use serde::{Deserialize, Serialize};

use crate::user::error::{Result, UserError};
use crate::user::repository::user::UserRecord;
use crate::PXID_GENERATOR;

use super::email::Email;
use super::password::Password;
use super::username::Username;

pub const USER_PXID_PREFIX: &str = "user";

#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct User {
pub id: Pxid,
pub name: String,
pub surname: String,
pub username: Username,
pub email: Email,
pub password: Password,
pub avatar_id: Option<Pxid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}

pub struct NewUserDto {
pub name: String,
pub surname: String,
pub username: String,
pub email: String,
pub password: String,
}

impl User {
pub fn new(dto: NewUserDto) -> Result<Self> {
let email = Email::from_str(&dto.email)?;
let username = Username::from_str(&dto.username)?;
let password = Password::from_str(&dto.password)?;

Ok(Self {
id: Self::generate_id()?,
name: dto.name,
surname: dto.surname,
username,
email,
password,
avatar_id: None,
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
})
}

pub fn generate_id() -> Result<Pxid> {
Pxid::new(USER_PXID_PREFIX).map_err(UserError::PxidError)
pub fn pxid() -> Result<Pxid> {
const USER_PXID_PREFIX: &str = "user";
Ok(PXID_GENERATOR.new_id(USER_PXID_PREFIX)?)
}
}

Expand All @@ -70,7 +41,6 @@ impl TryFrom<UserRecord> for User {
surname: value.surname,
username: Username(value.username.into()),
email: Email(value.email.into()),
password: Password(value.password_hash.into()),
avatar_id: value.avatar_id.map(|id| Pxid::from_str(&id)).transpose()?,
created_at: value.created_at,
updated_at: value.updated_at,
Expand Down
48 changes: 45 additions & 3 deletions crates/core/src/user/repository/user.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::str::FromStr;

use chrono::{DateTime, Utc};
use pxid::Pxid;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -49,6 +51,29 @@ pub struct UpdateUserDto {
pub surname: Option<String>,
}

impl TryFrom<entity::user::Model> for User {
type Error = UserError;

fn try_from(model: entity::user::Model) -> Result<Self> {
let username = Username::from_str(&model.username)?;
let email = Email::from_str(&model.email)?;

Ok(User {
id: Pxid::from_str(&model.id)?,
name: model.name,
surname: model.surname,
username,
email,
avatar_id: model.avatar_id.map(|id| Pxid::from_str(&id)).transpose()?,
created_at: DateTime::from_naive_utc_and_offset(model.created_at, Utc),
updated_at: DateTime::from_naive_utc_and_offset(model.updated_at, Utc),
deleted_at: model
.deleted_at
.map(|naive| DateTime::from_naive_utc_and_offset(naive, Utc)),
})
}
}

#[derive(Clone)]
pub struct UserRepository {
db: Database,
Expand Down Expand Up @@ -76,9 +101,9 @@ impl UserRepository {
}
}

pub async fn insert(&self, dto: InsertUserDto) -> Result<UserRecord> {
pub async fn insert(&self, dto: InsertUserDto) -> Result<User> {
let active_model = entity::user::ActiveModel {
id: Set(User::generate_id()?.to_string()),
id: Set(User::pxid()?.to_string()),
name: Set(dto.name),
surname: Set(dto.surname),
username: Set(dto.username.to_string()),
Expand Down Expand Up @@ -108,7 +133,7 @@ impl UserRepository {
}
})?;

Ok(UserRepository::into_record(model))
User::try_from(model)
}

pub async fn update(&self, id: Pxid, dto: UpdateUserDto) -> Result<UserRecord> {
Expand Down Expand Up @@ -216,6 +241,23 @@ impl UserRepository {
Ok(None)
}

pub async fn find_password_hash_by_email(&self, email: &Email) -> Result<Option<String>> {
let maybe_user = entity::prelude::User::find()
.filter(entity::user::Column::Email.eq(email.to_string()))
.one(&*self.db)
.await
.map_err(|err| {
tracing::error!(%err, %email, "Failed to find User by email");
UserError::DatabaseError
})?;

if let Some(user_model) = maybe_user {
return Ok(Some(user_model.password_hash));
}

Ok(None)
}

pub async fn find_by_id(&self, id: &Pxid) -> Result<Option<UserRecord>> {
let maybe_user = entity::prelude::User::find()
.filter(entity::user::Column::Id.eq(id.to_string()))
Expand Down
31 changes: 24 additions & 7 deletions crates/core/src/user/service.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use pxid::Pxid;

use crate::auth::service::AuthService;
use crate::image::model::UseCase;
use crate::image::service::{ImageProvider, ImageService, UploadImageDto};
use crate::shared::pagination::Pagination;
Expand Down Expand Up @@ -43,38 +44,37 @@ pub struct UploadAvatarDto {
#[derive(Clone)]
pub struct UserService<P: ImageProvider> {
repository: Box<UserRepository>,
auth_service: AuthService,
user_followers: Box<UserFollowersRepository>,
image_service: ImageService<P>,
}

impl<P: ImageProvider> UserService<P> {
pub fn new(
repository: UserRepository,
auth_service: AuthService,
user_followers: UserFollowersRepository,
image_service: ImageService<P>,
) -> Self {
Self {
auth_service,
repository: Box::new(repository),
user_followers: Box::new(user_followers),
image_service,
}
}

pub async fn create(&self, dto: CreateUserDto) -> Result<User> {
let record = self
.repository
self.repository
.insert(InsertUserDto {
id: User::generate_id()?.to_string(),
id: User::pxid()?.to_string(),
name: dto.name,
surname: dto.surname,
username: dto.username.to_string(),
email: dto.email.to_string(),
password_hash: dto.password.to_string(),
})
.await?;
let user = User::try_from(record)?;

Ok(user)
.await
}

pub async fn list(
Expand Down Expand Up @@ -117,6 +117,23 @@ impl<P: ImageProvider> UserService<P> {
Ok(None)
}

/// Verifies a users credentials by checking if the provided email and password
/// match the stored password hash.
pub async fn verify_credentials(&self, email: &Email, password: &String) -> Result<bool> {
let maybe_password_hash = self.repository.find_password_hash_by_email(email).await?;

if let Some(password_hash) = maybe_password_hash {
if self
.auth_service
.validate_password(&password_hash, password)
{
return Ok(true);
}
}

Ok(false)
}

/// Uploads a new avatar for the user. If the user already holds an avatar,
/// this first deletes the current avatar and uploads a new one.
pub async fn update_avatar(&self, id: Pxid, dto: UploadAvatarDto) -> Result<User> {
Expand Down
Loading

0 comments on commit cc3e2f9

Please sign in to comment.