feat: init
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
use crate::session::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CaptchaQuery {
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
pub dark: bool,
|
||||
pub rsa: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct CaptchaResponse {
|
||||
pub base64: String,
|
||||
pub rsa: Option<super::rsa::RsaResponse>,
|
||||
pub req: CaptchaQuery,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const CAPTCHA_KEY: &'static str = "captcha";
|
||||
const CAPTCHA_LENGTH: usize = 4;
|
||||
const CAPTCHA_MIN_WIDTH: u32 = 80;
|
||||
const CAPTCHA_MAX_WIDTH: u32 = 400;
|
||||
const CAPTCHA_MIN_HEIGHT: u32 = 30;
|
||||
const CAPTCHA_MAX_HEIGHT: u32 = 200;
|
||||
|
||||
pub async fn auth_captcha(
|
||||
&self,
|
||||
context: &Session,
|
||||
query: CaptchaQuery,
|
||||
) -> Result<CaptchaResponse, AppError> {
|
||||
let CaptchaQuery { w, h, dark, rsa } = query;
|
||||
if !(Self::CAPTCHA_MIN_WIDTH..=Self::CAPTCHA_MAX_WIDTH).contains(&w)
|
||||
|| !(Self::CAPTCHA_MIN_HEIGHT..=Self::CAPTCHA_MAX_HEIGHT).contains(&h)
|
||||
{
|
||||
return Err(AppError::BadRequest("invalid captcha size".into()));
|
||||
}
|
||||
|
||||
let captcha = captcha_rs::CaptchaBuilder::new()
|
||||
.width(w)
|
||||
.height(h)
|
||||
.dark_mode(dark)
|
||||
.length(Self::CAPTCHA_LENGTH)
|
||||
.build();
|
||||
|
||||
let base64 = captcha.to_base64();
|
||||
let text = captcha.text;
|
||||
context
|
||||
.insert(Self::CAPTCHA_KEY, text)
|
||||
.map_err(|_| AppError::InternalServerError("session insert failed".into()))?;
|
||||
|
||||
Ok(CaptchaResponse {
|
||||
base64,
|
||||
rsa: if rsa {
|
||||
Some(self.auth_rsa(context).await?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
req: CaptchaQuery { w, h, dark, rsa },
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_check_captcha(
|
||||
&self,
|
||||
context: &Session,
|
||||
captcha: String,
|
||||
) -> Result<(), AppError> {
|
||||
let text = context
|
||||
.get::<String>(Self::CAPTCHA_KEY)
|
||||
.map_err(|_| AppError::CaptchaError)?
|
||||
.ok_or(AppError::CaptchaError)?;
|
||||
if !constant_time_eq(&text.to_lowercase(), &captcha.to_lowercase()) {
|
||||
context.remove(Self::CAPTCHA_KEY);
|
||||
tracing::warn!("Captcha verification failed");
|
||||
return Err(AppError::CaptchaError);
|
||||
}
|
||||
context.remove(Self::CAPTCHA_KEY);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
use crate::service::util::constant_time_eq;
|
||||
@@ -0,0 +1,202 @@
|
||||
use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use sqlx::Row;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::UserMail;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct EmailChangeRequest {
|
||||
pub new_email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct EmailVerifyRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||
pub struct EmailResponse {
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
struct PendingEmailChange {
|
||||
user_uid: uuid::Uuid,
|
||||
new_email: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:";
|
||||
const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60;
|
||||
|
||||
pub async fn auth_get_email(&self, ctx: &Session) -> Result<EmailResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let email = sqlx::query_as::<_, UserMail>(
|
||||
"SELECT id, user_id, email, is_primary, is_verified, \
|
||||
verification_token_hash, verified_at, created_at, updated_at \
|
||||
FROM user_mail WHERE user_id = $1 AND is_verified = true",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(EmailResponse {
|
||||
email: email.map(|e| e.email),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_email_change_request(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: EmailChangeRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let new_email = params.new_email.trim().to_lowercase();
|
||||
if new_email.is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".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())
|
||||
.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::UserNotFound)?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &password_hash)
|
||||
.map_err(|_| AppError::InvalidPassword)?;
|
||||
|
||||
let existing = sqlx::query_as::<_, UserMail>(
|
||||
"SELECT id, user_id, email, is_primary, is_verified, \
|
||||
verification_token_hash, verified_at, created_at, updated_at \
|
||||
FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true",
|
||||
)
|
||||
.bind(&new_email)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if existing.is_some() {
|
||||
return Err(AppError::EmailExists);
|
||||
}
|
||||
|
||||
let token = super::generate_token("emc");
|
||||
let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, token);
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&cache_key,
|
||||
&PendingEmailChange {
|
||||
user_uid,
|
||||
new_email: new_email.clone(),
|
||||
},
|
||||
Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let domain = self.ctx.config.main_domain()?;
|
||||
let verify_link = format!("{}/auth/verify-email?token={}", domain, token);
|
||||
|
||||
let mut mail = self
|
||||
.ctx
|
||||
.registry
|
||||
.get_email_client()
|
||||
.ok_or(AppError::Config("mail service not available".into()))?;
|
||||
mail.send_email(tonic::Request::new(SendEmailRequest {
|
||||
to: vec![EmailAddress {
|
||||
email: new_email.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: "Confirm Email Change".into(),
|
||||
text_body: format!(
|
||||
"You requested to change your email address.\n\n\
|
||||
Confirm the change here:\n\n{}\n\n\
|
||||
If you did not request this change, ignore this email.",
|
||||
verify_link
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, new_email = %new_email, "Failed to send email change verification");
|
||||
AppError::InternalServerError(e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_email_verify(&self, params: EmailVerifyRequest) -> Result<(), AppError> {
|
||||
if params.token.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"missing email verification token".into(),
|
||||
));
|
||||
}
|
||||
let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, params.token);
|
||||
let pending =
|
||||
self.ctx
|
||||
.cache
|
||||
.get::<PendingEmailChange>(&cache_key)
|
||||
.ok_or(AppError::NotFound(
|
||||
"invalid or expired email verification token".into(),
|
||||
))?;
|
||||
|
||||
let existing = sqlx::query_as::<_, UserMail>(
|
||||
"SELECT id, user_id, email, is_primary, is_verified, \
|
||||
verification_token_hash, verified_at, created_at, updated_at \
|
||||
FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true",
|
||||
)
|
||||
.bind(&pending.new_email)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if existing.is_some() {
|
||||
return Err(AppError::EmailExists);
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
sqlx::query("UPDATE user_mail SET is_verified = false, updated_at = $1 WHERE user_id = $2")
|
||||
.bind(now)
|
||||
.bind(pending.user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, true, true, $4, $4)",
|
||||
)
|
||||
.bind(uuid::Uuid::now_v7())
|
||||
.bind(pending.user_uid)
|
||||
.bind(&pending.new_email)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use argon2::{
|
||||
Argon2, PasswordHash,
|
||||
password_hash::{PasswordHasher, PasswordVerifier},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::User;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct LoginParams {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub captcha: String,
|
||||
pub totp_code: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub const TOTP_KEY: &'static str = "totp_key";
|
||||
const TOTP_ATTEMPTS_PREFIX: &'static str = "auth:totp_attempts:";
|
||||
const TOTP_MAX_ATTEMPTS: u64 = 5;
|
||||
const TOTP_PENDING_TTL_SECS: u64 = 600;
|
||||
|
||||
#[tracing::instrument(skip(self, params, context), fields(username = %params.username))]
|
||||
pub async fn auth_login(&self, params: LoginParams, context: Session) -> Result<(), AppError> {
|
||||
let login = params.username.trim().to_string();
|
||||
let totp_pending = context
|
||||
.get::<String>(Self::TOTP_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
if !totp_pending {
|
||||
self.auth_check_captcha(&context, params.captcha).await?;
|
||||
}
|
||||
let password = self.auth_rsa_decode(&context, params.password).await?;
|
||||
|
||||
let user = match self.auth_find_user_by_username(&login).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => match self.auth_find_user_by_email(&login).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
let _ = Argon2::default().hash_password(
|
||||
password.as_bytes(),
|
||||
&argon2::password_hash::SaltString::generate(&mut rand::thread_rng()),
|
||||
);
|
||||
tracing::warn!(username = %login, "Login: user not found");
|
||||
return Err(AppError::UserNotFound);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT user_id, password_hash, password_algo, password_salt, \
|
||||
must_change_password, password_updated_at, created_at, updated_at \
|
||||
FROM user_password WHERE user_id = $1",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let row = row.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::UserNotFound)?;
|
||||
if Argon2::default()
|
||||
.verify_password(password.as_bytes(), &password_hash)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(username = %login, "Login: invalid password");
|
||||
return Err(AppError::UserNotFound);
|
||||
}
|
||||
|
||||
let two_factor_enabled = self.auth_2fa_status_by_uid(user.id).await?.is_enabled;
|
||||
if two_factor_enabled {
|
||||
if let Some(totp_session_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten() {
|
||||
let Some(ref totp_code) = params.totp_code else {
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
};
|
||||
let attempts_key = format!("{}{}", Self::TOTP_ATTEMPTS_PREFIX, totp_session_key);
|
||||
let attempts = self.ctx.cache.get::<u64>(&attempts_key).unwrap_or(0);
|
||||
if attempts >= Self::TOTP_MAX_ATTEMPTS {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
if !self
|
||||
.auth_2fa_verify_login(&context, user.id, totp_code)
|
||||
.await?
|
||||
{
|
||||
let next_attempts = attempts + 1;
|
||||
if next_attempts >= Self::TOTP_MAX_ATTEMPTS {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
} else {
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&attempts_key,
|
||||
&next_attempts,
|
||||
Some(std::time::Duration::from_secs(Self::TOTP_PENDING_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
}
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
} else {
|
||||
let totp_session_key = uuid::Uuid::new_v4().to_string();
|
||||
context
|
||||
.insert(Self::TOTP_KEY, totp_session_key.clone())
|
||||
.map_err(|_| AppError::InternalServerError("session insert failed".into()))?;
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&totp_session_key,
|
||||
&user.id,
|
||||
Some(std::time::Duration::from_secs(Self::TOTP_PENDING_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
tracing::info!(username = %login, "Login 2FA triggered");
|
||||
return Err(AppError::TwoFactorRequired);
|
||||
}
|
||||
} else if let Some(totp_session_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten()
|
||||
{
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let attempts_key = format!("{}{}", Self::TOTP_ATTEMPTS_PREFIX, totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE \"user\" SET last_login_at = $1, updated_at = $1 WHERE id = $2")
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
context.renew();
|
||||
context.set_user(user.id);
|
||||
context.remove(Self::RSA_PRIVATE_KEY);
|
||||
context.remove(Self::RSA_PUBLIC_KEY);
|
||||
tracing::info!(user_uid = %user.id, username = %user.username, "User logged in");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn auth_find_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<User, AppError> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \
|
||||
FROM \"user\" WHERE lower(username) = lower($1) AND is_active = true AND status = 'active' AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
pub(crate) async fn auth_find_user_by_email(&self, email: &str) -> Result<User, AppError> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.status, u.role, \
|
||||
u.visibility, u.is_active, u.is_bot, u.last_login_at, u.created_at, u.updated_at, u.deleted_at \
|
||||
FROM \"user\" u \
|
||||
INNER JOIN user_mail e ON e.user_id = u.id \
|
||||
WHERE lower(e.email) = lower($1) AND e.is_verified = true AND u.is_active = true AND u.status = 'active' AND u.deleted_at IS NULL",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
pub(crate) async fn auth_find_user_by_uid(&self, uid: uuid::Uuid) -> Result<User, AppError> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \
|
||||
FROM \"user\" WHERE id = $1 AND is_active = true AND status = 'active' AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl AuthService {
|
||||
pub async fn auth_logout(&self, context: &Session) -> Result<(), AppError> {
|
||||
if let Some(user_uid) = context.user() {
|
||||
tracing::info!(user_uid = %user_uid, "User logged out");
|
||||
}
|
||||
context.clear_user();
|
||||
context.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct ContextMe {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub has_unread_notifications: u64,
|
||||
pub language: String,
|
||||
pub timezone: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub async fn auth_me(&self, ctx: Session) -> Result<ContextMe, AppError> {
|
||||
let user_id = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let user = self.auth_find_user_by_uid(user_id).await?;
|
||||
let profile =
|
||||
crate::models::users::UserProfile::find_by_user_id(self.ctx.db.reader(), user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
Ok(ContextMe {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name.filter(|n| !n.is_empty()),
|
||||
avatar_url: user.avatar_url.filter(|u| !u.is_empty()),
|
||||
has_unread_notifications: 0,
|
||||
language: profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.language.clone())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| "en".to_string()),
|
||||
timezone: profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.timezone.clone())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| "UTC".to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
pub mod captcha;
|
||||
pub mod email;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod me;
|
||||
pub mod register;
|
||||
pub mod reset_pass;
|
||||
pub mod rsa;
|
||||
pub mod totp;
|
||||
|
||||
pub(crate) fn generate_token(prefix: &str) -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
use rand::Rng;
|
||||
let chars: String = (0..64)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..62);
|
||||
const CHARSET: &[u8] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
format!("{}_{}", prefix, chars)
|
||||
}
|
||||
|
||||
// constant_time_eq is provided by crate::service::util
|
||||
@@ -0,0 +1,239 @@
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, password_hash::PasswordHasher};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::User;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RegisterParams {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub captcha: String,
|
||||
pub email_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RegisterEmailCodeParams {
|
||||
pub email: String,
|
||||
pub captcha: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RegisterEmailCodeResponse {
|
||||
pub expires_in_secs: u64,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const REGISTER_EMAIL_CODE_PREFIX: &'static str = "auth:register_email:";
|
||||
const REGISTER_EMAIL_CODE_TTL_SECS: u64 = 600;
|
||||
const REGISTER_EMAIL_CODE_COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
pub async fn auth_register_email_code(
|
||||
&self,
|
||||
params: RegisterEmailCodeParams,
|
||||
context: &Session,
|
||||
) -> Result<RegisterEmailCodeResponse, AppError> {
|
||||
self.auth_check_captcha(context, params.captcha).await?;
|
||||
let email = params.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
|
||||
if self.auth_verified_email_exists(&email).await? {
|
||||
return Err(AppError::EmailExists);
|
||||
}
|
||||
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email);
|
||||
if self.ctx.cache.exists(&cooldown_key) {
|
||||
return Err(AppError::BadRequest(
|
||||
"verification code was sent recently; please try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let code = Self::generate_register_email_code();
|
||||
let cache_key = Self::register_email_code_key(&email);
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&cache_key,
|
||||
&code,
|
||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&cooldown_key,
|
||||
&true,
|
||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let mut mail = self
|
||||
.ctx
|
||||
.registry
|
||||
.get_email_client()
|
||||
.ok_or(AppError::Config("mail service not available".into()))?;
|
||||
mail.send_email(tonic::Request::new(SendEmailRequest {
|
||||
to: vec![EmailAddress {
|
||||
email: email.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: "Register Email Verification".into(),
|
||||
text_body: format!(
|
||||
"Your registration verification code is: {}\n\nThis code expires in 10 minutes.",
|
||||
code
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
Ok(RegisterEmailCodeResponse {
|
||||
expires_in_secs: Self::REGISTER_EMAIL_CODE_TTL_SECS,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
.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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_email_code_key(email: &str) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
Self::REGISTER_EMAIL_CODE_PREFIX,
|
||||
email.trim().to_lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_register_email_code() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
format!("{:06}", rng.gen_range(0..1_000_000))
|
||||
}
|
||||
|
||||
async fn auth_username_exists(&self, username: &str) -> Result<bool, AppError> {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE lower(username) = lower($1) AND deleted_at IS NULL)",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn auth_verified_email_exists(&self, email: &str) -> Result<bool, AppError> {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true)",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, params, context), fields(username = %params.username))]
|
||||
pub async fn auth_register(
|
||||
&self,
|
||||
params: RegisterParams,
|
||||
context: &Session,
|
||||
) -> Result<User, AppError> {
|
||||
self.auth_check_captcha(context, params.captcha).await?;
|
||||
let username = params.username.trim().to_string();
|
||||
let email = params.email.trim().to_lowercase();
|
||||
if username.is_empty() || email.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"username and email are required".into(),
|
||||
));
|
||||
}
|
||||
let password = self.auth_rsa_decode(context, params.password).await?;
|
||||
crate::service::util::validate_password_strength(&password)?;
|
||||
|
||||
let username_exists = self.auth_username_exists(&username).await?;
|
||||
let email_exists = self.auth_verified_email_exists(&email).await?;
|
||||
if username_exists || email_exists {
|
||||
return Err(AppError::AccountAlreadyExists);
|
||||
}
|
||||
|
||||
self.auth_check_register_email_code(&email, ¶ms.email_code)?;
|
||||
|
||||
let user_id = uuid::Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
let password_hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::PasswordHashError(e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO \"user\" \
|
||||
(id, username, display_name, status, role, visibility, is_active, is_bot, \
|
||||
last_login_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $2, 'active', 'user', 'public', true, false, NULL, $3, $3) \
|
||||
RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&username)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, true, true, $4, $4)",
|
||||
)
|
||||
.bind(uuid::Uuid::now_v7())
|
||||
.bind(user_id)
|
||||
.bind(&email)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_password (user_id, password_hash, password_algo, password_salt, \
|
||||
must_change_password, password_updated_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, 'argon2id', '', false, $3, $3, $3)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&password_hash)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
context.set_user(user_id);
|
||||
context.remove(Self::RSA_PRIVATE_KEY);
|
||||
context.remove(Self::RSA_PUBLIC_KEY);
|
||||
tracing::info!(user_uid = %user_id, username = %user.username, "User registered");
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ResetPasswordRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ResetPasswordVerifyParams {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct PendingResetPassword {
|
||||
user_uid: uuid::Uuid,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const RESET_PASS_PREFIX: &'static str = "auth:reset_pass:";
|
||||
const RESET_PASS_EXPIRY_HOURS: i64 = 1;
|
||||
const RESET_PASS_EXPIRY_SECS: u64 = 60 * 60;
|
||||
const RESET_PASS_COOLDOWN_SECS: u64 = 60; // 60 seconds between requests
|
||||
const RESET_PASS_DAILY_LIMIT: u64 = 5; // Max 5 requests per day
|
||||
const RESET_PASS_DAILY_SECS: u64 = 24 * 60 * 60; // 24 hours
|
||||
|
||||
pub async fn auth_reset_password_request(
|
||||
&self,
|
||||
params: ResetPasswordRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let email = params.email.trim().to_lowercase();
|
||||
|
||||
// Rate limiting: check cooldown
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email);
|
||||
if self.ctx.cache.exists(&cooldown_key) {
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
let user = self.auth_find_user_by_email(&email).await.ok();
|
||||
|
||||
if let Some(user) = user {
|
||||
let token = super::generate_token("rst");
|
||||
let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, token);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&cache_key,
|
||||
&PendingResetPassword {
|
||||
user_uid: user.id,
|
||||
created_at: now,
|
||||
},
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)),
|
||||
) {
|
||||
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) => {
|
||||
tracing::error!(error = %e, "Domain not configured for password reset");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let reset_link = format!("{}/auth/reset-password?token={}", domain, token);
|
||||
|
||||
let mut mail = match self.ctx.registry.get_email_client() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::error!("mail service not available");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Err(e) = mail
|
||||
.send_email(tonic::Request::new(SendEmailRequest {
|
||||
to: vec![EmailAddress {
|
||||
email: email.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: "Reset Your Password".into(),
|
||||
text_body: format!(
|
||||
"You requested to reset your password.\n\n\
|
||||
Reset your password here:\n\n{}\n\n\
|
||||
If you did not request this, ignore this email.",
|
||||
reset_link
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, email = %email, "Failed to send password reset email");
|
||||
}
|
||||
|
||||
tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_reset_password_verify(
|
||||
&self,
|
||||
context: &Session,
|
||||
params: ResetPasswordVerifyParams,
|
||||
) -> Result<(), AppError> {
|
||||
if params.token.is_empty() {
|
||||
return Err(AppError::InvalidResetToken);
|
||||
}
|
||||
|
||||
let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, params.token);
|
||||
let pending = self
|
||||
.ctx
|
||||
.cache
|
||||
.get::<PendingResetPassword>(&cache_key)
|
||||
.ok_or(AppError::InvalidResetToken)?;
|
||||
|
||||
if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) {
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
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 salt = SaltString::generate(&mut rand::thread_rng());
|
||||
let password_hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::PasswordHashError(e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let now = chrono::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(&password_hash)
|
||||
.bind(now)
|
||||
.bind(pending.user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::InvalidResetToken);
|
||||
}
|
||||
|
||||
tracing::info!(user_uid = %pending.user_uid, "Password reset successfully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use base64::Engine;
|
||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead};
|
||||
use hkdf::Hkdf;
|
||||
use rsa::{
|
||||
Oaep, RsaPrivateKey, RsaPublicKey,
|
||||
pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RsaResponse {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub const RSA_PRIVATE_KEY: &'static str = "rsa:private";
|
||||
pub const RSA_PUBLIC_KEY: &'static str = "rsa:public";
|
||||
const RSA_BIT_SIZE: usize = 2048;
|
||||
|
||||
fn derive_rsa_encryption_key(&self) -> Result<[u8; 32], AppError> {
|
||||
let secret = self
|
||||
.ctx
|
||||
.config
|
||||
.env
|
||||
.get("APP_SESSION_SECRET")
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| AppError::Config("APP_SESSION_SECRET is required".into()))?;
|
||||
let hk = Hkdf::<Sha256>::new(Some(b"rsa-session-encryption"), secret.as_bytes());
|
||||
let mut okm = [0u8; 32];
|
||||
hk.expand(b"rsa-private-key-aead", &mut okm)
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
Ok(okm)
|
||||
}
|
||||
|
||||
fn encrypt_rsa_key(&self, plaintext: &str) -> Result<String, AppError> {
|
||||
let key = self.derive_rsa_encryption_key()?;
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&key)
|
||||
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext.as_bytes())
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
|
||||
}
|
||||
|
||||
fn decrypt_rsa_key(&self, encrypted: &str) -> Result<String, AppError> {
|
||||
let key = self.derive_rsa_encryption_key()?;
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&key)
|
||||
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||
let combined = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted)
|
||||
.map_err(|_| AppError::RsaDecodeError)?;
|
||||
if combined.len() < 12 {
|
||||
return Err(AppError::RsaDecodeError);
|
||||
}
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes.copy_from_slice(&combined[..12]);
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, &combined[12..])
|
||||
.map_err(|_| AppError::RsaDecodeError)?;
|
||||
String::from_utf8(plaintext).map_err(|_| AppError::RsaDecodeError)
|
||||
}
|
||||
|
||||
pub async fn auth_rsa(&self, context: &Session) -> Result<RsaResponse, AppError> {
|
||||
if context
|
||||
.get::<String>(Self::RSA_PRIVATE_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
&& context
|
||||
.get::<String>(Self::RSA_PUBLIC_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
let public_key = context
|
||||
.get::<String>(Self::RSA_PUBLIC_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.expect("checked above");
|
||||
return Ok(RsaResponse { public_key });
|
||||
}
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let priv_key = RsaPrivateKey::new(&mut rng, Self::RSA_BIT_SIZE).map_err(|_| {
|
||||
tracing::error!("RSA key generation failed");
|
||||
AppError::RsaGenerationError
|
||||
})?;
|
||||
let pub_key = RsaPublicKey::from(&priv_key);
|
||||
let priv_pem = priv_key
|
||||
.to_pkcs1_pem(Default::default())
|
||||
.map_err(|_| AppError::RsaGenerationError)?
|
||||
.to_string();
|
||||
let public_key = pub_key
|
||||
.to_pkcs1_pem(Default::default())
|
||||
.map_err(|_| AppError::RsaGenerationError)?
|
||||
.to_string();
|
||||
|
||||
context
|
||||
.insert(Self::RSA_PRIVATE_KEY, self.encrypt_rsa_key(&priv_pem)?)
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
context
|
||||
.insert(Self::RSA_PUBLIC_KEY, public_key.clone())
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
|
||||
Ok(RsaResponse { public_key })
|
||||
}
|
||||
|
||||
pub async fn auth_rsa_decode(
|
||||
&self,
|
||||
context: &Session,
|
||||
data: String,
|
||||
) -> Result<String, AppError> {
|
||||
let encrypted_priv = context
|
||||
.get::<String>(Self::RSA_PRIVATE_KEY)
|
||||
.map_err(|_| AppError::RsaDecodeError)?
|
||||
.ok_or(AppError::RsaDecodeError)?;
|
||||
let priv_pem = self.decrypt_rsa_key(&encrypted_priv)?;
|
||||
|
||||
let priv_key = RsaPrivateKey::from_pkcs1_pem(&priv_pem).map_err(|_| {
|
||||
tracing::warn!("RSA decode failed: invalid private key");
|
||||
AppError::RsaDecodeError
|
||||
})?;
|
||||
let cipher = base64::engine::general_purpose::STANDARD
|
||||
.decode(&data)
|
||||
.map_err(|_| AppError::RsaDecodeError)?;
|
||||
let decrypted = priv_key
|
||||
.decrypt(Oaep::new::<Sha256>(), &cipher)
|
||||
.map_err(|_| {
|
||||
tracing::warn!("RSA decrypt failed");
|
||||
AppError::RsaDecodeError
|
||||
})?;
|
||||
Ok(String::from_utf8_lossy(&decrypted).to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
use sha2::Sha256;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::User2Fa;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Enable2FAResponse {
|
||||
pub secret: String,
|
||||
pub qr_code: String,
|
||||
pub backup_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Verify2FAParams {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Disable2FAParams {
|
||||
pub code: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Get2FAStatusResponse {
|
||||
pub is_enabled: bool,
|
||||
pub method: Option<String>,
|
||||
pub has_backup_codes: bool,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub async fn auth_2fa_enable(&self, context: &Session) -> Result<Enable2FAResponse, AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let user = self.auth_find_user_by_uid(user_uid).await?;
|
||||
|
||||
let existing = self.find_2fa(user_uid).await?;
|
||||
if existing.as_ref().is_some_and(|f| f.enabled) {
|
||||
return Err(AppError::TwoFactorAlreadyEnabled);
|
||||
}
|
||||
|
||||
let secret = Self::generate_totp_secret();
|
||||
let backup_codes = Self::generate_backup_codes(10);
|
||||
let qr_code = format!(
|
||||
"otpauth://totp/AppKS:{}?secret={}&issuer=AppKS",
|
||||
user.username, secret
|
||||
);
|
||||
let now = chrono::Utc::now();
|
||||
let hashed_backup_codes = self.hash_backup_codes(&backup_codes)?.join(".");
|
||||
|
||||
if existing.is_some() {
|
||||
sqlx::query(
|
||||
"UPDATE user_2fa SET secret = $1, backup_codes = $2, enabled = false, updated_at = $3 \
|
||||
WHERE user_id = $4",
|
||||
)
|
||||
.bind(&secret)
|
||||
.bind(&hashed_backup_codes)
|
||||
.bind(now)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_2fa (user_id, secret, backup_codes, enabled, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, false, $4, $4)",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(&secret)
|
||||
.bind(&hashed_backup_codes)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
Ok(Enable2FAResponse {
|
||||
secret,
|
||||
qr_code,
|
||||
backup_codes,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_verify_and_enable(
|
||||
&self,
|
||||
context: &Session,
|
||||
params: Verify2FAParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let two_fa = self
|
||||
.find_2fa(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if two_fa.enabled {
|
||||
return Err(AppError::TwoFactorAlreadyEnabled);
|
||||
}
|
||||
let secret = two_fa.secret.as_ref().ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if !self.verify_totp_code(secret, ¶ms.code)? {
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE user_2fa SET enabled = true, updated_at = $1 WHERE user_id = $2")
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_disable(
|
||||
&self,
|
||||
context: &Session,
|
||||
params: Disable2FAParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let password = self.auth_rsa_decode(context, params.password).await?;
|
||||
self.verify_user_password(user_uid, &password).await?;
|
||||
|
||||
let two_fa = self
|
||||
.find_2fa(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if !two_fa.enabled {
|
||||
return Err(AppError::TwoFactorNotEnabled);
|
||||
}
|
||||
if !self
|
||||
.verify_2fa_or_backup_code(&two_fa, ¶ms.code)
|
||||
.await?
|
||||
{
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM user_2fa WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_verify(&self, user_uid: Uuid, code: &str) -> Result<bool, AppError> {
|
||||
let Some(two_fa) = self.find_2fa(user_uid).await? else {
|
||||
return Ok(true);
|
||||
};
|
||||
if !two_fa.enabled {
|
||||
return Ok(true);
|
||||
}
|
||||
self.verify_2fa_or_backup_code(&two_fa, code).await
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_status_by_uid(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
) -> Result<Get2FAStatusResponse, AppError> {
|
||||
let Some(two_fa) = self.find_2fa(user_uid).await? else {
|
||||
return Ok(Get2FAStatusResponse {
|
||||
is_enabled: false,
|
||||
method: None,
|
||||
has_backup_codes: false,
|
||||
});
|
||||
};
|
||||
Ok(Get2FAStatusResponse {
|
||||
is_enabled: two_fa.enabled,
|
||||
method: Some("totp".into()),
|
||||
has_backup_codes: !two_fa.backup_codes.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_status(
|
||||
&self,
|
||||
context: &Session,
|
||||
) -> Result<Get2FAStatusResponse, AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
self.auth_2fa_status_by_uid(user_uid).await
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_verify_login(
|
||||
&self,
|
||||
context: &Session,
|
||||
expected_user_uid: Uuid,
|
||||
code: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
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 {
|
||||
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);
|
||||
tracing::warn!(expected_user_uid = %expected_user_uid, pending_user_uid = %user_uid, "2FA pending user mismatch");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let verified = self.auth_2fa_verify(user_uid, code).await?;
|
||||
if verified {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_key);
|
||||
}
|
||||
Ok(verified)
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_regenerate_backup_codes(
|
||||
&self,
|
||||
context: &Session,
|
||||
password: String,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let password = self.auth_rsa_decode(context, password).await?;
|
||||
self.verify_user_password(user_uid, &password).await?;
|
||||
let two_fa = self
|
||||
.find_2fa(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if !two_fa.enabled {
|
||||
return Err(AppError::TwoFactorNotEnabled);
|
||||
}
|
||||
|
||||
let backup_codes = Self::generate_backup_codes(10);
|
||||
sqlx::query("UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3")
|
||||
.bind(self.hash_backup_codes(&backup_codes)?.join("."))
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(backup_codes)
|
||||
}
|
||||
|
||||
fn generate_totp_secret() -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..32)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_backup_codes(count: usize) -> Vec<String> {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..count)
|
||||
.map(|_| {
|
||||
format!(
|
||||
"{:04}-{:04}-{:04}",
|
||||
rng.gen_range(0..10000),
|
||||
rng.gen_range(0..10000),
|
||||
rng.gen_range(0..10000)
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn backup_code_pepper(&self) -> Result<String, AppError> {
|
||||
self.ctx
|
||||
.config
|
||||
.env
|
||||
.get("APP_SESSION_SECRET")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| AppError::Config("APP_SESSION_SECRET is required".into()))
|
||||
}
|
||||
|
||||
fn hash_backup_code(&self, code: &str) -> Result<String, AppError> {
|
||||
let pepper = self.backup_code_pepper()?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(pepper.as_bytes())
|
||||
.map_err(|_| AppError::InternalServerError("invalid backup code pepper".into()))?;
|
||||
mac.update(code.trim().as_bytes());
|
||||
Ok(mac
|
||||
.finalize()
|
||||
.into_bytes()
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>())
|
||||
}
|
||||
|
||||
fn hash_backup_codes(&self, codes: &[String]) -> Result<Vec<String>, AppError> {
|
||||
codes.iter().map(|c| self.hash_backup_code(c)).collect()
|
||||
}
|
||||
|
||||
fn verify_totp_code(&self, secret: &str, code: &str) -> Result<bool, AppError> {
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
let time_step = 30;
|
||||
let counter = now / time_step;
|
||||
|
||||
for offset in [-1i64, 0, 1] {
|
||||
let test_counter = (counter as i64 + offset) as u64;
|
||||
let expected_code = self.generate_totp_code(secret, test_counter)?;
|
||||
if constant_time_eq(&expected_code, code) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn generate_totp_code(&self, secret: &str, counter: u64) -> Result<String, AppError> {
|
||||
let secret_bytes = Self::decode_base32(secret)?;
|
||||
let counter_bytes = counter.to_be_bytes();
|
||||
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(&secret_bytes)
|
||||
.map_err(|_| AppError::InvalidTwoFactorCode)?;
|
||||
mac.update(&counter_bytes);
|
||||
let result = mac.finalize().into_bytes();
|
||||
|
||||
let offset = (result[19] & 0x0f) as usize;
|
||||
let code = u32::from_be_bytes([
|
||||
result[offset] & 0x7f,
|
||||
result[offset + 1],
|
||||
result[offset + 2],
|
||||
result[offset + 3],
|
||||
]);
|
||||
|
||||
Ok(format!("{:06}", code % 1_000_000))
|
||||
}
|
||||
|
||||
fn decode_base32(input: &str) -> Result<Vec<u8>, AppError> {
|
||||
const CHARSET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let input = input.to_uppercase().replace("=", "");
|
||||
let mut bits = 0u64;
|
||||
let mut bit_count = 0;
|
||||
let mut output = Vec::new();
|
||||
|
||||
for c in input.chars() {
|
||||
let val = CHARSET.find(c).ok_or(AppError::InvalidTwoFactorCode)? as u64;
|
||||
bits = (bits << 5) | val;
|
||||
bit_count += 5;
|
||||
|
||||
if bit_count >= 8 {
|
||||
bit_count -= 8;
|
||||
output.push((bits >> bit_count) as u8);
|
||||
bits &= (1 << bit_count) - 1;
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
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())
|
||||
.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)?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &password_hash)
|
||||
.map_err(|_| AppError::InvalidPassword)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_2fa(&self, user_uid: Uuid) -> Result<Option<User2Fa>, AppError> {
|
||||
sqlx::query_as::<_, User2Fa>(
|
||||
"SELECT user_id, secret, backup_codes, enabled, created_at, updated_at \
|
||||
FROM user_2fa WHERE user_id = $1",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn verify_2fa_or_backup_code(
|
||||
&self,
|
||||
two_fa: &User2Fa,
|
||||
code: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let secret = two_fa.secret.as_ref().ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if self.verify_totp_code(secret, code)? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
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)?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
use crate::service::util::constant_time_eq;
|
||||
Reference in New Issue
Block a user