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

@ -25,3 +25,4 @@ axum-login = "0.18"
argon2 = "0.5"
time = "0.3"
thiserror = "1"
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname"] }

View file

@ -0,0 +1,12 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS email_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
expires_at BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);

View file

@ -30,7 +30,8 @@ impl AuthUser for db::User {
#[derive(Clone)]
pub struct Credentials {
pub username: String,
/// Accepts either a username or an email address.
pub login: String,
pub password: String,
}
@ -66,7 +67,7 @@ impl AuthnBackend for AuthBackend {
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let Some(user) = db::get_user_by_username(&self.pool, &creds.username).await? else {
let Some(user) = db::get_user_by_username_or_email(&self.pool, &creds.login).await? else {
return Ok(None);
};

View file

@ -15,6 +15,7 @@ pub struct User {
pub email: String,
pub password_hash: String,
pub created_at: i64,
pub email_verified: bool,
}
/// Aggregated game statistics for a user's public profile.
@ -54,7 +55,7 @@ impl DbError {
}
}
fn now_unix() -> i64 {
pub fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@ -83,12 +84,27 @@ pub async fn init_db(url: &str) -> Pool {
.batch_execute(include_str!("../migrations/002_participants_unique.sql"))
.await
.expect("Migration 002 failed");
client
.batch_execute(include_str!("../migrations/003_email_verification.sql"))
.await
.expect("Migration 003 failed");
pool
}
// ── Users ────────────────────────────────────────────────────────────────────
fn user_from_row(r: &tokio_postgres::Row) -> User {
User {
id: r.get("id"),
username: r.get("username"),
email: r.get("email"),
password_hash: r.get("password_hash"),
created_at: r.get("created_at"),
email_verified: r.get("email_verified"),
}
}
pub async fn create_user(
pool: &Pool,
username: &str,
@ -98,8 +114,8 @@ pub async fn create_user(
let client = pool.get().await?;
let row = client
.query_one(
"INSERT INTO users (username, email, password_hash, created_at) \
VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO users (username, email, password_hash, created_at, email_verified) \
VALUES ($1, $2, $3, $4, FALSE) RETURNING id",
&[&username, &email, &password_hash, &now_unix()],
)
.await?;
@ -110,33 +126,123 @@ pub async fn get_user_by_id(pool: &Pool, id: i64) -> Result<Option<User>, DbErro
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE id = $1",
&[&id],
)
.await?;
Ok(row.map(|r| User {
id: r.get("id"),
username: r.get("username"),
email: r.get("email"),
password_hash: r.get("password_hash"),
created_at: r.get("created_at"),
}))
Ok(row.as_ref().map(user_from_row))
}
pub async fn get_user_by_username(pool: &Pool, username: &str) -> Result<Option<User>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE username = $1",
&[&username],
)
.await?;
Ok(row.map(|r| User {
id: r.get("id"),
username: r.get("username"),
email: r.get("email"),
password_hash: r.get("password_hash"),
created_at: r.get("created_at"),
Ok(row.as_ref().map(user_from_row))
}
pub async fn get_user_by_email(pool: &Pool, email: &str) -> Result<Option<User>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE email = $1",
&[&email],
)
.await?;
Ok(row.as_ref().map(user_from_row))
}
/// Looks up a user by username first; if not found, tries by email.
pub async fn get_user_by_username_or_email(pool: &Pool, login: &str) -> Result<Option<User>, DbError> {
if let Some(u) = get_user_by_username(pool, login).await? {
return Ok(Some(u));
}
get_user_by_email(pool, login).await
}
pub async fn set_email_verified(pool: &Pool, user_id: i64) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"UPDATE users SET email_verified = TRUE WHERE id = $1",
&[&user_id],
)
.await?;
Ok(())
}
pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"UPDATE users SET password_hash = $1 WHERE id = $2",
&[&hash, &user_id],
)
.await?;
Ok(())
}
// ── Email tokens ──────────────────────────────────────────────────────────────
pub async fn create_email_token(
pool: &Pool,
user_id: i64,
token: &str,
kind: &str,
expires_at: i64,
) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"INSERT INTO email_tokens (user_id, token, kind, expires_at, created_at) \
VALUES ($1, $2, $3, $4, $5)",
&[&user_id, &token, &kind, &expires_at, &now_unix()],
)
.await?;
Ok(())
}
/// Removes all tokens of the given kind for a user (call before creating a fresh one).
pub async fn delete_email_tokens(pool: &Pool, user_id: i64, kind: &str) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"DELETE FROM email_tokens WHERE user_id = $1 AND kind = $2",
&[&user_id, &kind],
)
.await?;
Ok(())
}
/// Atomically deletes the token row and returns the `user_id` if the token
/// exists and has not expired. Returns `None` for missing or expired tokens.
pub async fn consume_email_token(
pool: &Pool,
token: &str,
kind: &str,
) -> Result<Option<i64>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"DELETE FROM email_tokens WHERE token = $1 AND kind = $2 \
RETURNING user_id, expires_at",
&[&token, &kind],
)
.await?;
Ok(row.and_then(|r| {
let expires_at: i64 = r.get("expires_at");
if expires_at >= now_unix() {
Some(r.get("user_id"))
} else {
None
}
}))
}

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)?;

View file

@ -13,6 +13,8 @@ use tokio::fs;
use tokio::sync::{Mutex, RwLock};
use tokio::sync::{broadcast, mpsc};
use crate::smtp::Mailer;
/// The game entry we have for one game.
#[derive(Serialize, Deserialize)]
pub struct GameEntry {
@ -59,14 +61,17 @@ pub struct AppState {
pub configs: RwLock<HashMap<String, u16>>,
/// PostgreSQL connection pool — shared across all request handlers.
pub db: Pool,
/// SMTP mailer for email verification and password reset.
pub mailer: Mailer,
}
impl AppState {
pub fn new(db: Pool) -> Self {
pub fn new(db: Pool, mailer: Mailer) -> Self {
Self {
rooms: Mutex::new(HashMap::new()),
configs: RwLock::new(HashMap::new()),
db,
mailer,
}
}
}

View file

@ -4,6 +4,7 @@ mod hand_shake;
mod http;
mod lobby;
mod message_relay;
mod smtp;
use crate::auth::AuthBackend;
use crate::hand_shake::{
@ -55,6 +56,8 @@ async fn main() {
.unwrap_or_else(|_| "postgresql://trictrac:trictrac@127.0.0.1:5432/trictrac".to_string());
let pool = db::init_db(&database_url).await;
let mailer = smtp::Mailer::from_env();
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
@ -63,7 +66,7 @@ async fn main() {
let auth_backend = AuthBackend::new(pool.clone());
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
let app_state = Arc::new(AppState::new(pool));
let app_state = Arc::new(AppState::new(pool, mailer));
let watchdog_state = app_state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min

View file

@ -0,0 +1,99 @@
//! SMTP mailer (plain SMTP, no TLS).
//!
//! Configured via environment variables:
//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev)
//! SMTP_PORT — default: 1025 (mailpit default)
//! SMTP_FROM — default: noreply@trictrac.local
//! SMTP_USER — optional SMTP credentials
//! SMTP_PASSWORD — optional SMTP credentials
//! APP_URL — default: http://localhost:9091 (frontend base URL for email links)
//!
//! For production with TLS, run a local relay (e.g. Postfix) or use an SMTP
//! service that accepts plain connections on a local port and handles TLS externally.
use lettre::{
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
message::Mailbox,
transport::smtp::authentication::Credentials as SmtpCredentials,
};
pub struct Mailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: Mailbox,
app_url: String,
}
impl Mailer {
pub fn from_env() -> Self {
let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(1025);
let from_str = std::env::var("SMTP_FROM")
.unwrap_or_else(|_| "noreply@trictrac.local".to_string());
let app_url = std::env::var("APP_URL")
.unwrap_or_else(|_| "http://localhost:9091".to_string());
let mut builder =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host).port(port);
if let (Ok(user), Ok(pass)) = (std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD")) {
builder = builder.credentials(SmtpCredentials::new(user, pass));
}
let transport = builder.build();
let from = from_str
.parse()
.unwrap_or_else(|_| "noreply@trictrac.local".parse().unwrap());
Self { transport, from, app_url }
}
pub async fn send_verification(&self, to_email: &str, token: &str) {
let link = format!("{}/verify-email?token={}", self.app_url, token);
let body = format!(
"Welcome to Trictrac!\n\n\
Please verify your email address by clicking the link below:\n\n\
{link}\n\n\
This link expires in 24 hours.\n"
);
self.send(to_email, "Verify your Trictrac account", body).await;
}
pub async fn send_password_reset(&self, to_email: &str, token: &str) {
let link = format!("{}/reset-password?token={}", self.app_url, token);
let body = format!(
"You requested a password reset for your Trictrac account.\n\n\
Click the link below to choose a new password:\n\n\
{link}\n\n\
This link expires in 1 hour.\n\
If you did not request this, you can safely ignore this email.\n"
);
self.send(to_email, "Reset your Trictrac password", body).await;
}
async fn send(&self, to_email: &str, subject: &str, body: String) {
let to: Mailbox = match to_email.parse() {
Ok(m) => m,
Err(e) => {
tracing::warn!("SMTP: invalid recipient address {to_email:?}: {e}");
return;
}
};
let email = match Message::builder()
.from(self.from.clone())
.to(to)
.subject(subject)
.body(body)
{
Ok(e) => e,
Err(e) => {
tracing::warn!("SMTP: failed to build message: {e}");
return;
}
};
if let Err(e) = self.transport.send(email).await {
tracing::warn!("SMTP: send failed: {e}");
}
}
}