Skip to content
Clone
80f9e01
197 lines 6.9 KB Rust
use models::users::{user, user_emails};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use session_layer::Session;
use time::OffsetDateTime;
use uuid::Uuid;
use super::AuthService;
use crate::errors::AuthError;
use crate::share::crypto::{
constant_time_eq, hash_password, random_token, sha256_b64url, validate_password,
};
use crate::share::rate::rate_limit_once;
#[derive(Serialize, Deserialize)]
struct StoredResetToken {
token_hash: String,
email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ForgotPasswordCmd {
pub email: String,
pub pow_answer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ForgotPasswordResp {
pub sent: bool,
pub temp_id: Uuid,
pub expires_in_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ResetPasswordCmd {
pub email: String,
pub token: String,
pub new_password: String,
pub temp_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ChangePasswordCmd {
pub old_password: String,
pub new_password: String,
}
impl AuthService {
#[tracing::instrument(name = "auth.forgot_password", skip_all)]
pub async fn forgot_password(
&self,
session: &Session,
cmd: ForgotPasswordCmd,
accept_language: Option<&str>,
) -> Result<ForgotPasswordResp, AuthError> {
self.verify_pow(session, &cmd.pow_answer)?;
let config = &self.config().auth;
let lower_email = cmd.email.trim().to_lowercase();
let rate_key = format!("auth:code_rate:forgot_pwd:{lower_email}");
rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?;
let temp_id = Uuid::now_v7();
let Some(email_row) = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(&lower_email))
.filter(user_emails::Column::DeletedAt.is_null())
.one(self.db().read())
.await?
else {
return Ok(ForgotPasswordResp {
sent: true,
temp_id,
expires_in_secs: config.reset_token_ttl_secs,
});
};
let token = random_token(config.reset_token_bytes);
let reset_key = format!("auth:reset:{temp_id}");
let stored = StoredResetToken {
token_hash: sha256_b64url(token.as_bytes()),
email: lower_email.clone(),
};
export::redis::set_string(
self.redis(),
&reset_key,
&serde_json::to_string(&stored)
.map_err(|error| AuthError::Internal(format!("encode reset token: {error}")))?,
config.reset_token_ttl_secs,
)
.await?;
let base_url = &self.config().web.web_base_url;
let locale = self
.mail_locale(Some(email_row.user_id), accept_language)
.await;
let subject = i18n::catalog::reset_subject(locale);
let html = crate::templates::mail_reset_link(
&lower_email,
&token,
&temp_id.to_string(),
base_url,
locale,
);
let body = i18n::catalog::reset_text(locale, &token);
let sent = self
.send_email(email_row.email.as_str(), subject, &body, html.0.as_str())
.await
.map(|_| true)
.unwrap_or_else(|error| {
tracing::warn!(%error, "failed to send reset email");
false
});
Ok(ForgotPasswordResp {
sent,
temp_id,
expires_in_secs: config.reset_token_ttl_secs,
})
}
#[tracing::instrument(name = "auth.reset_password", skip_all)]
pub async fn reset_password(&self, cmd: ResetPasswordCmd) -> Result<(), AuthError> {
let config = &self.config().auth;
validate_password(&cmd.new_password, &config.password_policy)?;
let reset_key = format!("auth:reset:{}", cmd.temp_id);
let Some(raw) = export::redis::getdel_string(self.redis(), &reset_key).await? else {
return Err(AuthError::ResetTokenInvalid);
};
let stored: StoredResetToken = serde_json::from_str(&raw)
.map_err(|error| AuthError::Internal(format!("decode reset token: {error}")))?;
let provided = cmd.token.trim();
let lower_email = cmd.email.trim().to_lowercase();
let valid = constant_time_eq(
stored.token_hash.as_bytes(),
sha256_b64url(provided.as_bytes()).as_bytes(),
) && stored.email == lower_email;
if !valid {
return Err(AuthError::ResetTokenInvalid);
}
let db = self.db();
let Some((_email_row, Some(user))) = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(&lower_email))
.filter(user_emails::Column::DeletedAt.is_null())
.find_also_related(user::Entity)
.one(db.write())
.await?
else {
return Err(AuthError::ResetTokenInvalid);
};
let password_hash = hash_password(&cmd.new_password)?;
let now = OffsetDateTime::now_utc();
let next_version = user.passwd_version.unwrap_or(0) + 1;
let mut active = user.into_active_model();
active.passwd_hash = Set(Some(password_hash));
active.passwd_version = Set(Some(next_version));
active.password_last_changed_at = Set(Some(now));
active.updated_at = Set(now);
active.update(db.write()).await?;
Ok(())
}
pub async fn change_password(
&self,
session: &Session,
uid: Uuid,
cmd: ChangePasswordCmd,
) -> Result<(), AuthError> {
let config = &self.config().auth;
validate_password(&cmd.new_password, &config.password_policy)?;
let db = self.db();
let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
return Err(AuthError::NotFound);
};
let stored = user
.passwd_hash
.as_deref()
.ok_or_else(|| AuthError::BadRequest("account has no password".to_string()))?;
if !crate::share::crypto::verify_password(&cmd.old_password, stored) {
return Err(AuthError::InvalidCredentials);
}
let password_hash = hash_password(&cmd.new_password)?;
let now = OffsetDateTime::now_utc();
let next_version = user.passwd_version.unwrap_or(0) + 1;
let mut active = user.into_active_model();
active.passwd_hash = Set(Some(password_hash));
active.passwd_version = Set(Some(next_version));
active.password_last_changed_at = Set(Some(now));
active.updated_at = Set(now);
active.update(db.write()).await?;
session_layer::share::session::apply_pwd_version(session, next_version);
Ok(())
}
}