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 axum::{extract::State, Json};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sqlx::SqlitePool;
|
|
||||||
|
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Seed generation
|
// Seed generation
|
||||||
@@ -97,18 +96,22 @@ struct ChallengeRow {
|
|||||||
///
|
///
|
||||||
/// Looks up today's challenge in the database. If none exists yet, generates
|
/// Looks up today's challenge in the database. If none exists yet, generates
|
||||||
/// one deterministically and stores it before returning.
|
/// 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(
|
pub async fn daily_challenge(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<ChallengeGoal>, AppError> {
|
) -> Result<Json<ChallengeGoal>, AppError> {
|
||||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
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!(
|
let row = sqlx::query_as!(
|
||||||
ChallengeRow,
|
ChallengeRow,
|
||||||
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
"SELECT goal_json FROM daily_challenges WHERE date = ?",
|
||||||
today
|
today
|
||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(r) = row {
|
if let Some(r) = row {
|
||||||
@@ -117,7 +120,10 @@ pub async fn daily_challenge(
|
|||||||
return Ok(Json(goal));
|
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 seed = hash_date_to_u64(&today);
|
||||||
let goal = generate_goal(&today, seed);
|
let goal = generate_goal(&today, seed);
|
||||||
let goal_json = serde_json::to_string(&goal)?;
|
let goal_json = serde_json::to_string(&goal)?;
|
||||||
@@ -129,10 +135,22 @@ pub async fn daily_challenge(
|
|||||||
seed_i64,
|
seed_i64,
|
||||||
goal_json
|
goal_json
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -21,23 +21,41 @@ use sqlx::SqlitePool;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
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`].
|
/// Construct the full Axum [`Router`].
|
||||||
///
|
///
|
||||||
/// Separated from `main` so it can be instantiated in integration tests without
|
/// Separated from `main` so it can be instantiated in integration tests without
|
||||||
/// starting a real TCP listener.
|
/// starting a real TCP listener.
|
||||||
pub fn build_router(pool: SqlitePool) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
build_router_inner(pool, true)
|
build_router_inner(state, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct the router without rate limiting.
|
/// Construct the router without rate limiting.
|
||||||
///
|
///
|
||||||
/// Intended for integration tests only — do not use in production.
|
/// 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)]
|
#[doc(hidden)]
|
||||||
pub fn build_test_router(pool: SqlitePool) -> Router {
|
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).
|
// Protected routes require a valid JWT (injected by require_auth middleware).
|
||||||
let protected = Router::new()
|
let protected = Router::new()
|
||||||
.route("/api/sync/pull", get(sync::pull))
|
.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", post(leaderboard::opt_in))
|
||||||
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
|
||||||
.route("/api/account", delete(auth::delete_account))
|
.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.
|
// Auth endpoints — rate-limited in production, unrestricted in tests.
|
||||||
let auth_routes = Router::new()
|
let auth_routes = Router::new()
|
||||||
@@ -80,7 +101,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router {
|
|||||||
.merge(public)
|
.merge(public)
|
||||||
// Reject request bodies larger than 1 MB.
|
// Reject request bodies larger than 1 MB.
|
||||||
.layer(DefaultBodyLimit::max(1024 * 1024))
|
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||||
.with_state(pool)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /health` — simple liveness probe, no auth required.
|
/// `GET /health` — simple liveness probe, no auth required.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
//! |---------------|---------|-------------------------------|
|
//! |---------------|---------|-------------------------------|
|
||||||
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
//! | `SERVER_PORT` | `8080` | TCP port to listen on |
|
||||||
|
|
||||||
use solitaire_server::build_router;
|
use solitaire_server::{build_router, AppState};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ async fn main() {
|
|||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
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")
|
let port: u16 = std::env::var("SERVER_PORT")
|
||||||
.unwrap_or_else(|_| "8080".into())
|
.unwrap_or_else(|_| "8080".into())
|
||||||
.parse()
|
.parse()
|
||||||
@@ -46,7 +48,8 @@ async fn main() {
|
|||||||
|
|
||||||
tracing::info!("database ready at {db_url}");
|
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));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
tracing::info!("listening on {addr}");
|
tracing::info!("listening on {addr}");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! can access it via `Extension<AuthenticatedUser>`.
|
//! can access it via `Extension<AuthenticatedUser>`.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRequestParts, Request},
|
extract::{FromRequestParts, Request, State},
|
||||||
http::request::Parts,
|
http::request::Parts,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
@@ -13,7 +13,7 @@ use axum::{
|
|||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
/// The claims encoded in our JWT access tokens.
|
/// The claims encoded in our JWT access tokens.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -37,18 +37,19 @@ pub struct AuthenticatedUser {
|
|||||||
/// Axum middleware function that validates the Bearer JWT and injects
|
/// Axum middleware function that validates the Bearer JWT and injects
|
||||||
/// [`AuthenticatedUser`] into request extensions.
|
/// [`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.
|
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
|
State(state): State<AppState>,
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> 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())
|
let token = extract_bearer_token(req.headers())
|
||||||
.ok_or(AppError::Unauthorized)?;
|
.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 {
|
req.extensions_mut().insert(AuthenticatedUser {
|
||||||
user_id: claims.sub,
|
user_id: claims.sub,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use solitaire_sync::{
|
|||||||
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{error::AppError, middleware::AuthenticatedUser};
|
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Database row helpers
|
// Database row helpers
|
||||||
@@ -99,10 +99,10 @@ async fn store_payload(
|
|||||||
///
|
///
|
||||||
/// If the user has never pushed any data, returns a default payload.
|
/// If the user has never pushed any data, returns a default payload.
|
||||||
pub async fn pull(
|
pub async fn pull(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
) -> Result<Json<SyncResponse>, AppError> {
|
) -> 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)?,
|
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||||
None => {
|
None => {
|
||||||
// First pull — no server data yet; return an empty default payload.
|
// 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
|
/// updated with the merged `best_single_score` and `fastest_win_seconds` so
|
||||||
/// scores stay in sync without a separate submission step.
|
/// scores stay in sync without a separate submission step.
|
||||||
pub async fn push(
|
pub async fn push(
|
||||||
State(pool): State<SqlitePool>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
Json(client_payload): Json<SyncPayload>,
|
Json(client_payload): Json<SyncPayload>,
|
||||||
) -> Result<Json<SyncResponse>, AppError> {
|
) -> Result<Json<SyncResponse>, AppError> {
|
||||||
@@ -143,12 +143,12 @@ pub async fn push(
|
|||||||
return Err(AppError::BadRequest("user_id mismatch".into()));
|
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)?,
|
Some(row) => row_to_payload(&row, &user.user_id)?,
|
||||||
None => {
|
None => {
|
||||||
// First push — nothing to merge against; store directly.
|
// First push — nothing to merge against; store directly.
|
||||||
store_payload(&pool, &user.user_id, &client_payload).await?;
|
store_payload(&state.pool, &user.user_id, &client_payload).await?;
|
||||||
update_leaderboard_if_opted_in(&pool, &user.user_id, &client_payload).await?;
|
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &client_payload).await?;
|
||||||
return Ok(Json(SyncResponse {
|
return Ok(Json(SyncResponse {
|
||||||
merged: client_payload,
|
merged: client_payload,
|
||||||
server_time: Utc::now(),
|
server_time: Utc::now(),
|
||||||
@@ -159,8 +159,8 @@ pub async fn push(
|
|||||||
|
|
||||||
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
let (merged, conflicts) = merge(&client_payload, &server_payload);
|
||||||
|
|
||||||
store_payload(&pool, &user.user_id, &merged).await?;
|
store_payload(&state.pool, &user.user_id, &merged).await?;
|
||||||
update_leaderboard_if_opted_in(&pool, &user.user_id, &merged).await?;
|
update_leaderboard_if_opted_in(&state.pool, &user.user_id, &merged).await?;
|
||||||
|
|
||||||
Ok(Json(SyncResponse {
|
Ok(Json(SyncResponse {
|
||||||
merged,
|
merged,
|
||||||
|
|||||||
Reference in New Issue
Block a user