fix: CI workflow, password reset flow, health/limits in services, session refactor, dead mixin stub cleanup

This commit is contained in:
loki5512344 2026-10-09 19:21:17 +02:00
parent 45dd592c40
commit f4e15b45c9
Signed by: boba
GPG key ID: 253067914055423B
95 changed files with 899 additions and 185 deletions

View file

@ -24,6 +24,17 @@ pub fn validate_register(
password: &str,
nick: &str,
) -> Result<(String, String), AppError> {
let email = normalize_email(email)?;
let nick = validate_nick(nick)?;
validate_password(password)?;
Ok((email, nick))
}
/// Trims and shape-checks an email address the same way for every auth flow
/// that accepts one (register, password reset request). The lookup itself is
/// case-insensitive: `accounts_email_lower_idx` is a unique index on
/// `lower(email)`, and `repo::find_by_email` lowercases the query value.
pub fn normalize_email(email: &str) -> Result<String, AppError> {
let email = email.trim();
if email.is_empty() {
return Err(AppError::Validation("email must not be empty".into()));
@ -42,7 +53,10 @@ pub fn validate_register(
"email must have exactly one '@' with non-empty parts".into(),
));
}
Ok(email.to_string())
}
fn validate_nick(nick: &str) -> Result<String, AppError> {
let nick = nick.trim();
let nick_len = nick.chars().count();
if nick_len == 0 || nick_len > 32 {
@ -55,7 +69,11 @@ pub fn validate_register(
"nick must not contain control characters".into(),
));
}
Ok(nick.to_string())
}
/// Password policy shared by registration and password reset.
pub fn validate_password(password: &str) -> Result<(), AppError> {
if password.chars().count() < 8 {
return Err(AppError::Validation(
"password must be at least 8 characters".into(),
@ -66,8 +84,7 @@ pub fn validate_register(
"password must be at most {MAX_PASSWORD_BYTES} bytes"
)));
}
Ok((email.to_string(), nick.to_string()))
Ok(())
}
#[cfg(test)]

View file

@ -1,6 +1,6 @@
use crate::accounts::model::validate_register;
use crate::accounts::repo;
use crate::auth::{password, tokens};
use crate::auth::{password, reset, tokens};
use crate::error::{AppError, AppJson};
use axum::{Json, extract::State, http::StatusCode};
use axum_extra::extract::cookie::CookieJar;
@ -13,6 +13,9 @@ pub struct AuthState {
pub jwt_secret: String,
pub cookie_secure: bool,
pub hasher: password::PasswordHasher,
/// Reset-email delivery back-end: Log in production (until a real
/// provider lands), Queue in tests.
pub mail: reset::MailBox,
// A real Argon2id hash of a throwaway string. `login` verifies against
// it when the email is unknown so that "no such account" costs the same
// ~100ms as "wrong password" — otherwise response time leaks which
@ -22,6 +25,15 @@ pub struct AuthState {
impl AuthState {
pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self {
Self::with_mail(pool, jwt_secret, cookie_secure, reset::MailBox::Log)
}
pub fn with_mail(
pool: sqlx::PgPool,
jwt_secret: String,
cookie_secure: bool,
mail: reset::MailBox,
) -> Self {
// Hashing a fixed, short constant with fixed valid params cannot
// fail; this is not user input, so the expect is a startup invariant.
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
@ -31,6 +43,7 @@ impl AuthState {
jwt_secret,
cookie_secure,
hasher: password::PasswordHasher::new(),
mail,
dummy_hash,
}
}

View file

@ -1,3 +1,4 @@
pub mod handlers;
pub mod password;
pub mod reset;
pub mod tokens;

View file

@ -0,0 +1,86 @@
use crate::accounts::{model, repo};
use crate::auth::reset;
use crate::error::{AppError, AppJson};
use axum::{Json, extract::State, http::StatusCode};
use serde::{Deserialize, Serialize};
use super::super::handlers::AuthState;
#[derive(Deserialize)]
pub struct ForgotPasswordRequest {
pub email: String,
}
#[derive(Serialize)]
pub struct AcceptedResponse {
pub status: &'static str,
}
/// POST /auth/forgot-password { email }
///
/// The response is identical whether or not the email is registered: no
/// oracle for account enumeration. Delivery happens out of band; the gateway
/// rate-limits this route per IP (3/hour) to keep the mailer from being
/// weaponised.
pub async fn forgot_password(
State(state): State<AuthState>,
AppJson(req): AppJson<ForgotPasswordRequest>,
) -> Result<(StatusCode, Json<AcceptedResponse>), AppError> {
let email = model::normalize_email(&req.email)?;
if let Some(account) = repo::find_by_email(&state.pool, &email).await? {
let token = reset::issue_reset_token(&state.pool, account.id).await?;
state.mail.send(&email, &token);
}
Ok((
StatusCode::ACCEPTED,
Json(AcceptedResponse {
status: "reset email sent if the account exists",
}),
))
}
#[derive(Deserialize)]
pub struct ResetPasswordRequest {
pub token: String,
pub new_password: String,
}
/// POST /auth/reset-password { token, new_password }
///
/// Consumes the token atomically, rewrites the password hash and revokes
/// every refresh session of the account. A bad token is a plain 400 with no
/// distinction between unknown, expired and already-used — all three are the
/// same "try again" situation from an attacker's point of view.
pub async fn reset_password(
State(state): State<AuthState>,
AppJson(req): AppJson<ResetPasswordRequest>,
) -> Result<Json<AcceptedResponse>, AppError> {
if !reset::is_well_formed_token(&req.token) {
return Err(AppError::Validation("malformed reset token".into()));
}
model::validate_password(&req.new_password)?;
let account_id = reset::consume_reset_token(&state.pool, &req.token)
.await?
.ok_or_else(|| AppError::Validation("reset token is invalid or expired".into()))?;
let hash = state
.hasher
.hash(req.new_password)
.await
.map_err(AppError::Internal)?;
let updated = sqlx::query("UPDATE accounts SET password_hash = $2 WHERE id = $1")
.bind(account_id)
.bind(&hash)
.execute(&state.pool)
.await?;
if updated.rows_affected() != 1 {
// The token row referenced a cascade-deleted account.
return Err(AppError::Validation(
"reset token is invalid or expired".into(),
));
}
reset::revoke_all_sessions(&state.pool, account_id).await?;
Ok(Json(AcceptedResponse {
status: "password updated",
}))
}

View file

@ -0,0 +1,129 @@
pub mod handlers;
use sqlx::PgPool;
use tokio::sync::mpsc::UnboundedSender;
use uuid::Uuid;
use crate::auth::tokens::{hash_token, new_opaque_token};
/// One outgoing reset email. `token` is the plaintext token: the only place
/// it ever exists outside the response of `issue_reset_token`.
#[derive(Debug, Clone)]
pub struct Mail {
pub to: String,
pub token: String,
}
/// Delivery back-end for reset emails.
#[derive(Clone)]
pub enum MailBox {
/// Development delivery: a structured log line carrying the token, which
/// local/dev stacks pick up from the container logs. When a real provider
/// is wired in (lettre/SES/anything), this variant is the single switch
/// point - the endpoint contract does not change.
Log,
/// In-process queue: tests (and a future in-process mail worker) receive
/// every mail exactly as the handler produced it.
Queue(UnboundedSender<Mail>),
}
impl MailBox {
pub fn send(&self, to: &str, token: &str) {
match self {
MailBox::Log => {
tracing::info!(
account = %to,
reset_token = %token,
"password reset requested; deliver the reset link to the account owner"
);
}
MailBox::Queue(tx) => {
let _ = tx.send(Mail {
to: to.to_string(),
token: token.to_string(),
});
}
}
}
}
/// Reset links must be used quickly: long windows turn a leaked email into
/// an account takeover. 30 minutes is the common industry compromise.
const TTL_MINUTES: i64 = 30;
pub const TOKEN_PREFIX: &str = "lvpr_";
/// A reset token is base64url like every other opaque token in this service
/// ("lvpr_" prefix + 43 chars), so the length check alone filters out most
/// junk before the database is ever touched.
pub fn is_well_formed_token(token: &str) -> bool {
token.len() == TOKEN_PREFIX.len() + 43 && token.starts_with(TOKEN_PREFIX)
}
/// Invalidates any token still pending for the account, then stores the hash
/// of a fresh one. Returns the plaintext token for the mailer only — it is
/// never persisted in clear form.
pub async fn issue_reset_token(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Error> {
sqlx::query(
"UPDATE password_reset_tokens SET used_at = now()
WHERE account_id = $1 AND used_at IS NULL",
)
.bind(account_id)
.execute(pool)
.await?;
let token = new_opaque_token(TOKEN_PREFIX);
sqlx::query(
"INSERT INTO password_reset_tokens (account_id, token_hash, expires_at)
VALUES ($1, $2, now() + make_interval(mins => $3::int))",
)
.bind(account_id)
.bind(hash_token(&token))
.bind(TTL_MINUTES)
.execute(pool)
.await?;
Ok(token)
}
/// Atomically marks the token as used and returns its account. The single
/// conditional UPDATE makes double-spend impossible even for concurrent
/// callers: exactly one of them gets the row back.
pub async fn consume_reset_token(pool: &PgPool, token: &str) -> Result<Option<Uuid>, sqlx::Error> {
let row: Option<(Uuid,)> = sqlx::query_as(
"UPDATE password_reset_tokens SET used_at = now()
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING account_id",
)
.bind(hash_token(token))
.fetch_optional(pool)
.await?;
Ok(row.map(|(id,)| id))
}
/// Kill every refresh session of the account: whoever holds a stolen session
/// cookie must not survive a password change.
pub async fn revoke_all_sessions(pool: &PgPool, account_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE refresh_tokens SET revoked_at = now()
WHERE account_id = $1 AND revoked_at IS NULL",
)
.bind(account_id)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn well_formed_token_shape_matches_opaque_generator() {
let token = new_opaque_token(TOKEN_PREFIX);
assert!(is_well_formed_token(&token));
assert!(!is_well_formed_token(&format!("{token}x")));
assert!(!is_well_formed_token("lvpr_short"));
assert!(!is_well_formed_token(
"XWpr_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
));
}
}

View file

@ -16,9 +16,21 @@ use axum::{
};
use config::Config;
use device::{handlers::DeviceState, store::DeviceStore};
use tower_http::trace::TraceLayer;
pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
let auth_state = AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure);
build_app_with_mail(pool, cfg, auth::reset::MailBox::Log)
}
/// Same router with a custom reset-email back-end: production uses the log
/// mailer, tests capture tokens through the queue variant.
pub fn build_app_with_mail(pool: sqlx::PgPool, cfg: &Config, mail: auth::reset::MailBox) -> Router {
let auth_state = AuthState::with_mail(
pool.clone(),
cfg.jwt_secret.clone(),
cfg.cookie_secure,
mail,
);
let device_state = DeviceState {
store: DeviceStore::default(),
pool: pool.clone(),
@ -43,6 +55,14 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
.route("/auth/login", post(auth::handlers::login))
.route("/auth/refresh", post(auth::handlers::refresh))
.route("/auth/logout", post(auth::handlers::logout))
.route(
"/auth/forgot-password",
post(auth::reset::handlers::forgot_password),
)
.route(
"/auth/reset-password",
post(auth::reset::handlers::reset_password),
)
.with_state(auth_state);
let device_routes = Router::new()
@ -87,4 +107,5 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
.layer(TraceLayer::new_for_http())
}

View file

@ -2,7 +2,12 @@ use accounts_service::{build_app, config::Config};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
dotenvy::dotenv().ok();
let cfg = Config::from_env()?;
cfg.validate()?;