fix(server): load JWT_SECRET at startup, add auth logging, fix challenge race
- Introduce AppState { pool, jwt_secret } so JWT_SECRET is loaded once in
main() and any missing value is a fatal startup error rather than a 500
on the first request. All four env::var("JWT_SECRET") call sites in
auth.rs and middleware.rs are replaced with state.jwt_secret.
- build_test_router embeds the fixed test secret so integration tests do
not need to set JWT_SECRET in the environment.
- Add tracing::warn! in login (invalid password) and register (username
taken) to surface brute-force attempts in production logs.
- Fix daily-challenge race condition: after INSERT OR IGNORE, re-SELECT
the persisted row so concurrent requests both return the winner's data.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,10 @@
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use chrono::Utc;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use solitaire_sync::ChallengeGoal;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::{error::AppError, AppState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed generation
|
||||
@@ -97,18 +96,22 @@ struct ChallengeRow {
|
||||
///
|
||||
/// Looks up today's challenge in the database. If none exists yet, generates
|
||||
/// one deterministically and stores it before returning.
|
||||
///
|
||||
/// The `INSERT OR IGNORE` followed by a re-SELECT ensures that concurrent
|
||||
/// requests racing to create today's row all return the same persisted value
|
||||
/// rather than each returning their own locally-generated copy.
|
||||
pub async fn daily_challenge(
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ChallengeGoal>, AppError> {
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
// Try to load an existing row.
|
||||
// Try to load an existing row first (fast path — no generation needed).
|
||||
let row = sqlx::query_as!(
|
||||
ChallengeRow,
|
||||
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
||||
today
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(r) = row {
|
||||
@@ -117,7 +120,10 @@ pub async fn daily_challenge(
|
||||
return Ok(Json(goal));
|
||||
}
|
||||
|
||||
// No row yet — generate and store.
|
||||
// No row yet — generate the goal locally and attempt to store it.
|
||||
// `INSERT OR IGNORE` means a concurrent request that wins the race will
|
||||
// silently ignore our insert. We then re-SELECT to ensure both requests
|
||||
// return the same persisted row regardless of which one won.
|
||||
let seed = hash_date_to_u64(&today);
|
||||
let goal = generate_goal(&today, seed);
|
||||
let goal_json = serde_json::to_string(&goal)?;
|
||||
@@ -129,10 +135,22 @@ pub async fn daily_challenge(
|
||||
seed_i64,
|
||||
goal_json
|
||||
)
|
||||
.execute(&pool)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(goal))
|
||||
// Re-SELECT to return exactly what is stored — handles the race where
|
||||
// another request inserted a row between our initial SELECT and INSERT.
|
||||
let stored = sqlx::query_as!(
|
||||
ChallengeRow,
|
||||
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
||||
today
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let stored_json = stored.goal_json.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?;
|
||||
let stored_goal: ChallengeGoal = serde_json::from_str(&stored_json)?;
|
||||
Ok(Json(stored_goal))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -21,23 +21,41 @@ use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
||||
|
||||
/// Shared application state injected into every Axum handler via [`axum::extract::State`].
|
||||
///
|
||||
/// Loaded once at startup so a missing `JWT_SECRET` causes an immediate startup
|
||||
/// failure rather than a 500 error on the first request.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// SQLite connection pool.
|
||||
pub pool: SqlitePool,
|
||||
/// HS256 signing secret for JWT access and refresh tokens.
|
||||
pub jwt_secret: String,
|
||||
}
|
||||
|
||||
/// Construct the full Axum [`Router`].
|
||||
///
|
||||
/// Separated from `main` so it can be instantiated in integration tests without
|
||||
/// starting a real TCP listener.
|
||||
pub fn build_router(pool: SqlitePool) -> Router {
|
||||
build_router_inner(pool, true)
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
build_router_inner(state, true)
|
||||
}
|
||||
|
||||
/// Construct the router without rate limiting.
|
||||
///
|
||||
/// Intended for integration tests only — do not use in production.
|
||||
/// Uses a fixed test JWT secret (`"test_secret_32_chars_minimum_ok!"`) so
|
||||
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
||||
#[doc(hidden)]
|
||||
pub fn build_test_router(pool: SqlitePool) -> Router {
|
||||
build_router_inner(pool, false)
|
||||
let state = AppState {
|
||||
pool,
|
||||
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
|
||||
};
|
||||
build_router_inner(state, false)
|
||||
}
|
||||
|
||||
fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
||||
fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
// Protected routes require a valid JWT (injected by require_auth middleware).
|
||||
let protected = Router::new()
|
||||
.route("/api/sync/pull", get(sync::pull))
|
||||
@@ -46,7 +64,10 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
||||
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
|
||||
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||
.route("/api/account", delete(auth::delete_account))
|
||||
.layer(axum_middleware::from_fn(middleware::require_auth));
|
||||
.layer(axum_middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::require_auth,
|
||||
));
|
||||
|
||||
// Auth endpoints — rate-limited in production, unrestricted in tests.
|
||||
let auth_routes = Router::new()
|
||||
@@ -80,7 +101,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
||||
.merge(public)
|
||||
// Reject request bodies larger than 1 MB.
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||
.with_state(pool)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// `GET /health` — simple liveness probe, no auth required.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! |---------------|---------|-------------------------------|
|
||||
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
||||
|
||||
use solitaire_server::build_router;
|
||||
use solitaire_server::{build_router, AppState};
|
||||
use sqlx::SqlitePool;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
@@ -29,6 +29,8 @@ async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
// Load JWT_SECRET once at startup — a missing secret is a fatal configuration error.
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let port: u16 = std::env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "8080".into())
|
||||
.parse()
|
||||
@@ -46,7 +48,8 @@ async fn main() {
|
||||
|
||||
tracing::info!("database ready at {db_url}");
|
||||
|
||||
let app = build_router(pool);
|
||||
let state = AppState { pool, jwt_secret };
|
||||
let app = build_router(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
tracing::info!("listening on {addr}");
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! can access it via `Extension<AuthenticatedUser>`.
|
||||
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Request},
|
||||
extract::{FromRequestParts, Request, State},
|
||||
http::request::Parts,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
@@ -13,7 +13,7 @@ use axum::{
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::{error::AppError, AppState};
|
||||
|
||||
/// The claims encoded in our JWT access tokens.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -37,18 +37,19 @@ pub struct AuthenticatedUser {
|
||||
/// Axum middleware function that validates the Bearer JWT and injects
|
||||
/// [`AuthenticatedUser`] into request extensions.
|
||||
///
|
||||
/// Reads the JWT secret from [`AppState`] rather than the environment, so a
|
||||
/// missing secret causes a startup failure rather than a per-request 500.
|
||||
///
|
||||
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
|
||||
pub async fn require_auth(
|
||||
State(state): State<AppState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
let token = extract_bearer_token(req.headers())
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims = validate_access_token(&token, &secret)?;
|
||||
let claims = validate_access_token(&token, &state.jwt_secret)?;
|
||||
|
||||
req.extensions_mut().insert(AuthenticatedUser {
|
||||
user_id: claims.sub,
|
||||
|
||||
@@ -11,7 +11,7 @@ use solitaire_sync::{
|
||||
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
||||
};
|
||||
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser};
|
||||
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database row helpers
|
||||
@@ -99,10 +99,10 @@ async fn store_payload(
|
||||
///
|
||||
/// If the user has never pushed any data, returns a default payload.
|
||||
pub async fn pull(
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<SyncResponse>, AppError> {
|
||||
let stored_payload = match load_sync_row(&pool, &user.user_id).await? {
|
||||
let stored_payload = match load_sync_row(&state.pool, &user.user_id).await? {
|
||||
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||
None => {
|
||||
// First pull — no server data yet; return an empty default payload.
|
||||
@@ -134,7 +134,7 @@ pub async fn pull(
|
||||
/// updated with the merged `best_single_score` and `fastest_win_seconds` so
|
||||
/// scores stay in sync without a separate submission step.
|
||||
pub async fn push(
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(client_payload): Json<SyncPayload>,
|
||||
) -> Result<Json<SyncResponse>, AppError> {
|
||||
@@ -143,12 +143,12 @@ pub async fn push(
|
||||
return Err(AppError::BadRequest("user_id mismatch".into()));
|
||||
}
|
||||
|
||||
let server_payload = match load_sync_row(&pool, &user.user_id).await? {
|
||||
let server_payload = match load_sync_row(&state.pool, &user.user_id).await? {
|
||||
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||
None => {
|
||||
// First push — nothing to merge against; store directly.
|
||||
store_payload(&pool, &user.user_id, &client_payload).await?;
|
||||
update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?;
|
||||
store_payload(&state.pool, &user.user_id, &client_payload).await?;
|
||||
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &client_payload).await?;
|
||||
return Ok(Json(SyncResponse {
|
||||
merged: client_payload,
|
||||
server_time: Utc::now(),
|
||||
@@ -159,8 +159,8 @@ pub async fn push(
|
||||
|
||||
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
||||
|
||||
store_payload(&pool, &user.user_id, &merged).await?;
|
||||
update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?;
|
||||
store_payload(&state.pool, &user.user_id, &merged).await?;
|
||||
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?;
|
||||
|
||||
Ok(Json(SyncResponse {
|
||||
merged,
|
||||
|
||||
Reference in New Issue
Block a user