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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user