feat: add email verification & password reset link

This commit is contained in:
Henri Bourcereau 2026-05-03 21:31:36 +02:00
parent 440bf12c43
commit d24f850882
20 changed files with 928 additions and 62 deletions

View file

@ -1,12 +1,17 @@
//! HTTP endpoints for user management (Phases 2 & 4).
//! HTTP endpoints for user management.
//!
//! Routes:
//! POST /auth/register
//! POST /auth/login
//! POST /auth/logout
//! GET /auth/me
//! GET /auth/verify-email?token=…
//! POST /auth/resend-verification
//! POST /auth/forgot-password
//! POST /auth/reset-password
//! GET /users/:username
//! GET /users/:username/games?page=0&per_page=20
//! GET /games/:id
//! POST /games/result
use axum::{
@ -17,15 +22,20 @@ use axum::{
routing::{get, post},
};
use axum_login::AuthSession;
use rand::distributions::Alphanumeric;
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::sync::Arc;
use crate::auth::{AuthBackend, Credentials, hash_password};
use crate::db;
use crate::db::{self, now_unix};
use crate::lobby::AppState;
const VERIFY_TOKEN_EXPIRY: i64 = 86_400; // 24 hours
const RESET_TOKEN_EXPIRY: i64 = 3_600; // 1 hour
// ── Router ────────────────────────────────────────────────────────────────────
pub fn router() -> Router<Arc<AppState>> {
@ -34,12 +44,26 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/auth/login", post(login))
.route("/auth/logout", post(logout))
.route("/auth/me", get(me))
.route("/auth/verify-email", get(verify_email))
.route("/auth/resend-verification", post(resend_verification))
.route("/auth/forgot-password", post(forgot_password))
.route("/auth/reset-password", post(reset_password))
.route("/users/{username}", get(user_profile))
.route("/users/{username}/games", get(user_games))
.route("/games/result", post(game_result))
.route("/games/{id}", get(game_detail))
}
// ── Token generation ──────────────────────────────────────────────────────────
fn generate_token() -> String {
rand::thread_rng()
.sample_iter(Alphanumeric)
.take(64)
.map(char::from)
.collect()
}
// ── Error type ────────────────────────────────────────────────────────────────
enum AppError {
@ -88,10 +112,27 @@ struct LoginBody {
password: String,
}
#[derive(Deserialize)]
struct TokenQuery {
token: String,
}
#[derive(Deserialize)]
struct ForgotPasswordBody {
email: String,
}
#[derive(Deserialize)]
struct ResetPasswordBody {
token: String,
new_password: String,
}
#[derive(Serialize)]
struct MeResponse {
id: i64,
username: String,
email_verified: bool,
}
#[derive(Serialize)]
@ -147,7 +188,7 @@ impl From<db::GameSummary> for GameSummaryResponse {
}
}
// ── Handlers ──────────────────────────────────────────────────────────────────
// ── Auth handlers ─────────────────────────────────────────────────────────────
async fn register(
mut auth_session: AuthSession<AuthBackend>,
@ -180,6 +221,16 @@ async fn register(
.await?
.ok_or(AppError::Internal)?;
// Send verification email (best-effort).
let token = generate_token();
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
if db::create_email_token(&state.db, user_id, &token, "verify", expires_at)
.await
.is_ok()
{
state.mailer.send_verification(&body.email, &token).await;
}
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
Ok((
@ -187,6 +238,7 @@ async fn register(
Json(MeResponse {
id: user.id,
username: user.username,
email_verified: user.email_verified,
}),
))
}
@ -196,7 +248,7 @@ async fn login(
Json(body): Json<LoginBody>,
) -> Result<impl IntoResponse, AppError> {
let creds = Credentials {
username: body.username,
login: body.username,
password: body.password,
};
@ -211,6 +263,7 @@ async fn login(
Ok(Json(MeResponse {
id: user.id,
username: user.username,
email_verified: user.email_verified,
}))
}
@ -224,12 +277,86 @@ async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse,
Some(user) => Ok(Json(MeResponse {
id: user.id,
username: user.username,
email_verified: user.email_verified,
})
.into_response()),
None => Ok(StatusCode::UNAUTHORIZED.into_response()),
}
}
async fn verify_email(
State(state): State<Arc<AppState>>,
Query(params): Query<TokenQuery>,
) -> Result<StatusCode, AppError> {
let user_id = db::consume_email_token(&state.db, &params.token, "verify")
.await?
.ok_or(AppError::BadRequest("invalid or expired token"))?;
db::set_email_verified(&state.db, user_id).await?;
Ok(StatusCode::OK)
}
async fn resend_verification(
auth_session: AuthSession<AuthBackend>,
State(state): State<Arc<AppState>>,
) -> Result<StatusCode, AppError> {
let user = auth_session.user.ok_or(AppError::Unauthorized)?;
if user.email_verified {
return Ok(StatusCode::OK);
}
db::delete_email_tokens(&state.db, user.id, "verify").await?;
let token = generate_token();
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
db::create_email_token(&state.db, user.id, &token, "verify", expires_at).await?;
state.mailer.send_verification(&user.email, &token).await;
Ok(StatusCode::OK)
}
async fn forgot_password(
State(state): State<Arc<AppState>>,
Json(body): Json<ForgotPasswordBody>,
) -> StatusCode {
// Always return 200 to avoid leaking which email addresses are registered.
if let Ok(Some(user)) = db::get_user_by_email(&state.db, &body.email).await {
let _ = db::delete_email_tokens(&state.db, user.id, "reset").await;
let token = generate_token();
let expires_at = now_unix() + RESET_TOKEN_EXPIRY;
if db::create_email_token(&state.db, user.id, &token, "reset", expires_at)
.await
.is_ok()
{
state.mailer.send_password_reset(&body.email, &token).await;
}
}
StatusCode::OK
}
async fn reset_password(
State(state): State<Arc<AppState>>,
Json(body): Json<ResetPasswordBody>,
) -> Result<StatusCode, AppError> {
if body.new_password.len() < 8 {
return Err(AppError::BadRequest("password must be at least 8 characters"));
}
let user_id = db::consume_email_token(&state.db, &body.token, "reset")
.await?
.ok_or(AppError::BadRequest("invalid or expired token"))?;
let hash = hash_password(&body.new_password).map_err(|_| AppError::Internal)?;
db::update_password_hash(&state.db, user_id, &hash).await?;
Ok(StatusCode::OK)
}
// ── Profile handlers ──────────────────────────────────────────────────────────
async fn user_profile(
Path(username): Path<String>,
State(state): State<Arc<AppState>>,
@ -270,7 +397,7 @@ async fn user_games(
}))
}
// ── Game detail (Phase 5) ─────────────────────────────────────────────────────
// ── Game detail ───────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ParticipantWithUsername {
@ -338,7 +465,7 @@ async fn game_detail(
}))
}
// ── Game result recording (Phase 4) ──────────────────────────────────────────
// ── Game result recording ─────────────────────────────────────────────────────
#[derive(Deserialize)]
struct GameResultBody {
@ -368,7 +495,6 @@ async fn game_result(
) -> Result<impl IntoResponse, AppError> {
let compound_id = format!("{}#{}", body.room_code, body.game_id);
// Snapshot the fields we need while holding the lock, then release immediately.
let (game_record_id, user_ids) = {
let rooms = state.rooms.lock().await;
let room = rooms.get(&compound_id).ok_or(AppError::NotFound)?;