fix(dimentorin): verify-email validates OTP before activating user
- new app_otp_cache table + OtpCache entity (ResourceEnum::OtpCache) - PostgresOtpRepository upsert/find/delete keyed by email - register/resend persist otp_hash+expiry after email sent (no orphan OTP) - verify_email validates via OtpManager::validate_otp_hash, single-use delete - 8 unit tests pass, e2e verified: wrong OTP 400, correct OTP 200
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use crate::auth::domain::otp::{OtpCacheRecord, OtpRepository};
|
||||
use crate::auth::domain::AuthService;
|
||||
use crate::auth::domain::types::{
|
||||
AuthTokens, AuthUserDetail, LoginInput, LoginOutput, NewPasswordInput,
|
||||
@@ -22,16 +23,19 @@ use uuid::Uuid;
|
||||
pub struct AuthServiceImpl {
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
role_repo: Arc<dyn RoleRepository>,
|
||||
otp_repo: Arc<dyn OtpRepository>,
|
||||
}
|
||||
|
||||
impl AuthServiceImpl {
|
||||
pub fn new(
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
role_repo: Arc<dyn RoleRepository>,
|
||||
otp_repo: Arc<dyn OtpRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_repo,
|
||||
role_repo,
|
||||
otp_repo,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +191,16 @@ impl AuthService for AuthServiceImpl {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
// Persist OTP only after user creation succeeded, so a failed
|
||||
// registration leaves no orphan OTP record behind.
|
||||
self
|
||||
.otp_repo
|
||||
.save(OtpCacheRecord {
|
||||
email: payload.email.clone(),
|
||||
otp_hash: otp.hash.clone(),
|
||||
expires_at: otp.expires_at,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -203,6 +217,16 @@ impl AuthService for AuthServiceImpl {
|
||||
&format!("Your OTP code is {}", otp.code),
|
||||
)
|
||||
.map_err(|e| AppError::BadRequestError(e.to_string()))?;
|
||||
// Overwrite stored OTP only after the email was actually sent, so a
|
||||
// failed resend never invalidates the previous (still valid) code.
|
||||
self
|
||||
.otp_repo
|
||||
.save(OtpCacheRecord {
|
||||
email: payload.email.clone(),
|
||||
otp_hash: otp.hash.clone(),
|
||||
expires_at: otp.expires_at,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -265,6 +289,24 @@ impl AuthService for AuthServiceImpl {
|
||||
if user.is_active {
|
||||
return Err(AppError::BadRequestError("User already active".into()));
|
||||
}
|
||||
let stored = self
|
||||
.otp_repo
|
||||
.find_by_email(&payload.email)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::BadRequestError(
|
||||
"No OTP issued for this email, request a new code".into(),
|
||||
)
|
||||
})?;
|
||||
if !OtpManager::validate_otp_hash(
|
||||
&stored.otp_hash,
|
||||
&stored.expires_at,
|
||||
payload.otp,
|
||||
) {
|
||||
return Err(AppError::BadRequestError(
|
||||
"Invalid or expired OTP".into(),
|
||||
));
|
||||
}
|
||||
self
|
||||
.user_repo
|
||||
.update(UserEntity {
|
||||
@@ -272,6 +314,8 @@ impl AuthService for AuthServiceImpl {
|
||||
..user
|
||||
})
|
||||
.await?;
|
||||
// OTP is single-use — consume it on successful verification.
|
||||
self.otp_repo.delete_by_email(&payload.email).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod otp;
|
||||
pub mod types;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use imphnen_utils::AppError;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OtpCacheRecord {
|
||||
pub email: String,
|
||||
pub otp_hash: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OtpRepository: Send + Sync {
|
||||
/// Upsert OTP record keyed by email (one active OTP per email).
|
||||
async fn save(&self, record: OtpCacheRecord) -> Result<(), AppError>;
|
||||
async fn find_by_email(&self, email: &str) -> Result<OtpCacheRecord, AppError>;
|
||||
async fn delete_by_email(&self, email: &str) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use super::handlers::{
|
||||
};
|
||||
use crate::auth::application::AuthServiceImpl;
|
||||
use crate::auth::domain::AuthService;
|
||||
use crate::auth::infrastructure::PostgresOtpRepository;
|
||||
use crate::roles::infrastructure::persistence::PostgresRoleRepository;
|
||||
use crate::users::infrastructure::persistence::PostgresUserRepository;
|
||||
use axum::{Extension, Router, routing::post};
|
||||
@@ -18,8 +19,11 @@ pub fn auth_public_routes(_db: DatabaseConnection, state: Arc<AppState>) -> Rout
|
||||
let role_repo = Arc::new(PostgresRoleRepository::new(
|
||||
state.postgres_connection.conn.clone(),
|
||||
));
|
||||
let otp_repo = Arc::new(PostgresOtpRepository::new(
|
||||
state.postgres_connection.conn.clone(),
|
||||
));
|
||||
let auth_service: Arc<dyn AuthService> =
|
||||
Arc::new(AuthServiceImpl::new(user_repo, role_repo));
|
||||
Arc::new(AuthServiceImpl::new(user_repo, role_repo, otp_repo));
|
||||
Router::new()
|
||||
.route("/auth/login", post(post_login))
|
||||
.route("/auth/login-mentor", post(post_login_mentor))
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
|
||||
pub use persistence::postgres_otp_repository::PostgresOtpRepository;
|
||||
|
||||
@@ -1 +1 @@
|
||||
|
||||
pub mod postgres_otp_repository;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
use crate::auth::domain::otp::{OtpCacheRecord, OtpRepository};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use imphnen_entities::seaorm::common::otp_cache::{
|
||||
ActiveModel as OtpCacheActiveModel, Column as OtpCacheColumn, Entity as OtpCacheEntity,
|
||||
};
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use sea_orm::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct PostgresOtpRepository {
|
||||
db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PostgresOtpRepository {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db: Arc::new(db) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OtpRepository for PostgresOtpRepository {
|
||||
async fn save(&self, record: OtpCacheRecord) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
let otp_hash = record.otp_hash.clone();
|
||||
let expires_at = record.expires_at;
|
||||
let active_model = OtpCacheActiveModel {
|
||||
id: Set(uuid::Uuid::new_v4()),
|
||||
email: Set(record.email.clone()),
|
||||
otp_hash: Set(otp_hash.clone()),
|
||||
expires_at: Set(expires_at),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
|
||||
// Upsert: replace any existing (possibly expired) OTP for the same email.
|
||||
let existing = OtpCacheEntity::find()
|
||||
.filter(OtpCacheColumn::Email.eq(record.email.clone()))
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
if let Some(existing) = existing {
|
||||
let mut update: OtpCacheActiveModel = existing.into();
|
||||
update.otp_hash = Set(otp_hash);
|
||||
update.expires_at = Set(expires_at);
|
||||
update.updated_at = Set(now);
|
||||
update
|
||||
.update(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
} else {
|
||||
OtpCacheEntity::insert(active_model)
|
||||
.exec(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &str) -> Result<OtpCacheRecord, AppError> {
|
||||
let row = OtpCacheEntity::find()
|
||||
.filter(OtpCacheColumn::Email.eq(email))
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("No OTP issued for this email".into()))?;
|
||||
Ok(OtpCacheRecord {
|
||||
email: row.email,
|
||||
otp_hash: row.otp_hash,
|
||||
expires_at: row.expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_by_email(&self, email: &str) -> Result<(), AppError> {
|
||||
OtpCacheEntity::delete_many()
|
||||
.filter(OtpCacheColumn::Email.eq(email))
|
||||
.exec(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user