feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum tags, integrations, invitations, repo links, slash commands, stages, voice, webhooks - Add PR service modules: review requests, templates - Add repo service modules: contributors, release assets, git extras (archive, branch rename, commit extras, diff/merge, tag, tree) - Add user service: social (follow/block) - Add internal auth service - Update existing service modules with expanded functionality - Remove deleted IM modules: articles, delivery trace, drafts, follows, messages, polls, presence, reactions, threads
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
use argon2::{
|
||||
Argon2, PasswordHasher,
|
||||
password_hash::{PasswordHash, PasswordVerifier, SaltString},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ChangePasswordParams {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub async fn auth_change_password(
|
||||
&self,
|
||||
session: &Session,
|
||||
params: ChangePasswordParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = session.user().ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let current_password = self
|
||||
.auth_rsa_decode(session, params.current_password)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
|
||||
let hash: String = row.try_get("password_hash").map_err(AppError::Database)?;
|
||||
let password_hash = PasswordHash::new(&hash).map_err(|_| AppError::InvalidPassword)?;
|
||||
|
||||
if Argon2::default()
|
||||
.verify_password(current_password.as_bytes(), &password_hash)
|
||||
.is_err()
|
||||
{
|
||||
return Err(AppError::InvalidPassword);
|
||||
}
|
||||
|
||||
let new_password = self.auth_rsa_decode(session, params.new_password).await?;
|
||||
crate::service::util::validate_password_strength(&new_password)?;
|
||||
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
let new_hash = Argon2::default()
|
||||
.hash_password(new_password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::PasswordHashError(e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"UPDATE user_password SET password_hash = $1, password_updated_at = $2, updated_at = $2 \
|
||||
WHERE user_id = $3",
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.bind(now)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::UserNotFound);
|
||||
}
|
||||
|
||||
session.remove(Self::RSA_PRIVATE_KEY);
|
||||
session.remove(Self::RSA_PUBLIC_KEY);
|
||||
|
||||
tracing::info!(user_uid = %user_uid, "Password changed successfully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -35,6 +35,7 @@ struct PendingEmailChange {
|
||||
impl AuthService {
|
||||
const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:";
|
||||
const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60;
|
||||
const EMAIL_CHANGE_COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
pub async fn auth_get_email(&self, ctx: &Session) -> Result<EmailResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
@@ -63,11 +64,20 @@ impl AuthService {
|
||||
if new_email.is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
|
||||
// Rate limiting: check cooldown
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid);
|
||||
if self.ctx.cache.exists(&cooldown_key).await {
|
||||
return Err(AppError::BadRequest(
|
||||
"email change request was sent recently; please try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let password = self.auth_rsa_decode(ctx, params.password).await?;
|
||||
|
||||
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.fetch_optional(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
@@ -103,6 +113,7 @@ impl AuthService {
|
||||
},
|
||||
Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let domain = self.ctx.config.main_domain()?;
|
||||
@@ -133,6 +144,16 @@ impl AuthService {
|
||||
AppError::InternalServerError(e.to_string())
|
||||
})?;
|
||||
|
||||
// Set cooldown after successful email send
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid);
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&cooldown_key,
|
||||
&true,
|
||||
Some(Duration::from_secs(Self::EMAIL_CHANGE_COOLDOWN_SECS)),
|
||||
).await {
|
||||
tracing::warn!(error = %e, "Failed to set email change cooldown");
|
||||
}
|
||||
|
||||
tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent");
|
||||
Ok(())
|
||||
}
|
||||
@@ -148,6 +169,7 @@ impl AuthService {
|
||||
self.ctx
|
||||
.cache
|
||||
.get::<PendingEmailChange>(&cache_key)
|
||||
.await
|
||||
.ok_or(AppError::NotFound(
|
||||
"invalid or expired email verification token".into(),
|
||||
))?;
|
||||
@@ -195,7 +217,7 @@ impl AuthService {
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
let _ = self.ctx.cache.delete(&cache_key).await;
|
||||
tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ impl AuthService {
|
||||
if let Some(user_uid) = context.user() {
|
||||
tracing::info!(user_uid = %user_uid, "User logged out");
|
||||
}
|
||||
context.clear_user();
|
||||
context.clear();
|
||||
context.purge();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod captcha;
|
||||
pub mod change_password;
|
||||
pub mod email;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
|
||||
@@ -51,7 +51,7 @@ impl AuthService {
|
||||
}
|
||||
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email);
|
||||
if self.ctx.cache.exists(&cooldown_key) {
|
||||
if self.ctx.cache.exists(&cooldown_key).await {
|
||||
return Err(AppError::BadRequest(
|
||||
"verification code was sent recently; please try again later".into(),
|
||||
));
|
||||
@@ -66,6 +66,7 @@ impl AuthService {
|
||||
&code,
|
||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
self.ctx
|
||||
.cache
|
||||
@@ -74,6 +75,7 @@ impl AuthService {
|
||||
&true,
|
||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let mut mail = self
|
||||
@@ -101,17 +103,18 @@ impl AuthService {
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> {
|
||||
async fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> {
|
||||
let cache_key = Self::register_email_code_key(email);
|
||||
let stored = self
|
||||
.ctx
|
||||
.cache
|
||||
.get::<String>(&cache_key)
|
||||
.await
|
||||
.ok_or(AppError::InvalidEmailCode)?;
|
||||
if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) {
|
||||
return Err(AppError::InvalidEmailCode);
|
||||
}
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
let _ = self.ctx.cache.delete(&cache_key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -171,7 +174,7 @@ impl AuthService {
|
||||
return Err(AppError::AccountAlreadyExists);
|
||||
}
|
||||
|
||||
self.auth_check_register_email_code(&email, ¶ms.email_code)?;
|
||||
self.auth_check_register_email_code(&email, ¶ms.email_code).await?;
|
||||
|
||||
let user_id = uuid::Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
@@ -230,6 +233,7 @@ impl AuthService {
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
context.renew();
|
||||
context.set_user(user_id);
|
||||
context.remove(Self::RSA_PRIVATE_KEY);
|
||||
context.remove(Self::RSA_PUBLIC_KEY);
|
||||
|
||||
+26
-24
@@ -41,14 +41,14 @@ impl AuthService {
|
||||
|
||||
// Rate limiting: check cooldown
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email);
|
||||
if self.ctx.cache.exists(&cooldown_key) {
|
||||
if self.ctx.cache.exists(&cooldown_key).await {
|
||||
tracing::warn!(email = %email, "Password reset request rate limited (cooldown)");
|
||||
return Ok(()); // Don't reveal if email exists
|
||||
}
|
||||
|
||||
// Rate limiting: check daily limit
|
||||
let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email);
|
||||
let daily_count: u64 = self.ctx.cache.get(&daily_key).unwrap_or(0);
|
||||
let daily_count: u64 = self.ctx.cache.get(&daily_key).await.unwrap_or(0);
|
||||
if daily_count >= Self::RESET_PASS_DAILY_LIMIT {
|
||||
tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)");
|
||||
return Ok(()); // Don't reveal if email exists
|
||||
@@ -68,30 +68,11 @@ impl AuthService {
|
||||
created_at: now,
|
||||
},
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)),
|
||||
) {
|
||||
).await {
|
||||
tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Set cooldown
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&cooldown_key,
|
||||
&true,
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)),
|
||||
) {
|
||||
tracing::warn!(error = %e, "Failed to set cooldown");
|
||||
}
|
||||
|
||||
// Increment daily counter
|
||||
let new_count = daily_count + 1;
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&daily_key,
|
||||
&new_count,
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)),
|
||||
) {
|
||||
tracing::warn!(error = %e, "Failed to increment daily counter");
|
||||
}
|
||||
|
||||
let domain = match self.ctx.config.main_domain() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
@@ -126,6 +107,26 @@ impl AuthService {
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, email = %email, "Failed to send password reset email");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Set cooldown only after successful email send
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&cooldown_key,
|
||||
&true,
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)),
|
||||
).await {
|
||||
tracing::warn!(error = %e, "Failed to set cooldown");
|
||||
}
|
||||
|
||||
// Increment daily counter only after successful email send
|
||||
let new_count = daily_count + 1;
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&daily_key,
|
||||
&new_count,
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)),
|
||||
).await {
|
||||
tracing::warn!(error = %e, "Failed to increment daily counter");
|
||||
}
|
||||
|
||||
tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent");
|
||||
@@ -148,16 +149,17 @@ impl AuthService {
|
||||
.ctx
|
||||
.cache
|
||||
.get::<PendingResetPassword>(&cache_key)
|
||||
.await
|
||||
.ok_or(AppError::InvalidResetToken)?;
|
||||
|
||||
if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) {
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
let _ = self.ctx.cache.delete(&cache_key).await;
|
||||
return Err(AppError::ResetTokenExpired);
|
||||
}
|
||||
|
||||
let password = self.auth_rsa_decode(context, params.password).await?;
|
||||
crate::service::util::validate_password_strength(&password)?;
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
let _ = self.ctx.cache.delete(&cache_key).await;
|
||||
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
let password_hash = Argon2::default()
|
||||
|
||||
+17
-25
@@ -192,13 +192,13 @@ impl AuthService {
|
||||
let Some(totp_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(user_uid) = self.ctx.cache.get::<Uuid>(&totp_key) else {
|
||||
let Some(user_uid) = self.ctx.cache.get::<Uuid>(&totp_key).await else {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
return Ok(false);
|
||||
};
|
||||
if user_uid != expected_user_uid {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_key);
|
||||
let _ = self.ctx.cache.delete(&totp_key).await;
|
||||
tracing::warn!(expected_user_uid = %expected_user_uid, pending_user_uid = %user_uid, "2FA pending user mismatch");
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -206,7 +206,7 @@ impl AuthService {
|
||||
let verified = self.auth_2fa_verify(user_uid, code).await?;
|
||||
if verified {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_key);
|
||||
let _ = self.ctx.cache.delete(&totp_key).await;
|
||||
}
|
||||
Ok(verified)
|
||||
}
|
||||
@@ -349,7 +349,7 @@ impl AuthService {
|
||||
async fn verify_user_password(&self, user_uid: Uuid, password: &str) -> Result<(), AppError> {
|
||||
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.fetch_optional(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
@@ -368,7 +368,7 @@ impl AuthService {
|
||||
FROM user_2fa WHERE user_id = $1",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.fetch_optional(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
@@ -384,26 +384,18 @@ impl AuthService {
|
||||
}
|
||||
|
||||
let hashed_code = self.hash_backup_code(code)?;
|
||||
let mut backup_codes: Vec<String> = two_fa
|
||||
.backup_codes
|
||||
.split('.')
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
if backup_codes
|
||||
.iter()
|
||||
.any(|stored| constant_time_eq(stored, &hashed_code))
|
||||
{
|
||||
backup_codes.retain(|stored| stored != &hashed_code);
|
||||
sqlx::query(
|
||||
"UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3",
|
||||
)
|
||||
.bind(backup_codes.join("."))
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(two_fa.user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE user_2fa SET backup_codes = regexp_replace(backup_codes, $1, ''), updated_at = $2 \
|
||||
WHERE user_id = $3 AND backup_codes LIKE '%' || $1 || '%'",
|
||||
)
|
||||
.bind(&hashed_code)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(two_fa.user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
|
||||
Reference in New Issue
Block a user