feat(workspace): full server + sync implementation, all tests green
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily challenge, account deletion, JWT middleware, rate limiting via tower_governor, SQLite migrations, health endpoint - solitaire_server: expose build_test_router (no rate limiting) so integration tests work without a peer IP in oneshot requests - solitaire_sync: SyncPayload, merge logic, shared API types - solitaire_data: SyncProvider trait, LocalOnlyProvider, SolitaireServerClient, auth_tokens keyring integration, blanket Box<dyn SyncProvider> impl - solitaire_data/settings: derive Default on SyncBackend (clippy fix) - .sqlx/: offline query cache so server compiles without a live DB - sqlx: removed non-existent "offline" feature flag - keyring v2: fixed Entry::new() returning Result<Entry> - sqlx 0.8: all SQLite TEXT columns wrapped in Option<T> - Integration tests: max_connections(1) on in-memory pool so all connections share the same schema All 191 tests pass; cargo clippy -D warnings clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
//! Authentication handlers: register, login, refresh, delete account.
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use bcrypt::{hash, verify};
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::AppError,
|
||||
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Body for `POST /api/auth/register` and `POST /api/auth/login`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Body for `POST /api/auth/refresh`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Successful auth response — contains both tokens.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Successful refresh response — contains only the new access token.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RefreshResponse {
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal database row type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// User row fetched from the database during login.
|
||||
/// Fields are `Option<String>` because sqlx treats all SQLite TEXT columns
|
||||
/// as nullable regardless of the NOT NULL constraint in the schema.
|
||||
struct UserRow {
|
||||
id: Option<String>,
|
||||
password_hash: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bcrypt cost used for password hashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// bcrypt cost factor. Per ARCHITECTURE.md §19 this must be 12.
|
||||
const BCRYPT_COST: u32 = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token generation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Encode a JWT access token (24-hour expiry) for `user_id`.
|
||||
pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
||||
let exp = (Utc::now() + chrono::Duration::hours(24)).timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: "access".to_string(),
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
|
||||
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<String, AppError> {
|
||||
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
kind: "refresh".to_string(),
|
||||
};
|
||||
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `POST /api/auth/register` — create a new account and return tokens.
|
||||
pub async fn register(
|
||||
State(pool): State<SqlitePool>,
|
||||
Json(body): Json<AuthRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
// Validate input minimally.
|
||||
if body.username.trim().is_empty() || body.password.is_empty() {
|
||||
return Err(AppError::BadRequest("username and password are required".into()));
|
||||
}
|
||||
|
||||
// Check for duplicate username. SQLite returns TEXT as nullable so we
|
||||
// flatten the Option<Option<String>> produced by fetch_optional.
|
||||
let existing: Option<String> = sqlx::query_scalar!(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
body.username
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::UsernameTaken);
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4().to_string();
|
||||
let password_hash = hash(&body.password, BCRYPT_COST)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
user_id,
|
||||
body.username,
|
||||
password_hash,
|
||||
now
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: make_access_token(&user_id, &secret)?,
|
||||
refresh_token: make_refresh_token(&user_id, &secret)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/auth/login` — verify credentials and return tokens.
|
||||
pub async fn login(
|
||||
State(pool): State<SqlitePool>,
|
||||
Json(body): Json<AuthRequest>,
|
||||
) -> Result<Json<AuthResponse>, AppError> {
|
||||
let row = sqlx::query_as!(
|
||||
UserRow,
|
||||
"SELECT id, password_hash FROM users WHERE username = ?",
|
||||
body.username
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or(AppError::InvalidCredentials)?;
|
||||
let row_id = row.id.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
||||
let row_hash = row.password_hash.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
|
||||
|
||||
let valid = verify(&body.password, &row_hash)?;
|
||||
if !valid {
|
||||
return Err(AppError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token: make_access_token(&row_id, &secret)?,
|
||||
refresh_token: make_refresh_token(&row_id, &secret)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/auth/refresh` — exchange a refresh token for a new access token.
|
||||
pub async fn refresh(
|
||||
Json(body): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, AppError> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
let claims = validate_refresh_token(&body.refresh_token, &secret)?;
|
||||
|
||||
Ok(Json(RefreshResponse {
|
||||
access_token: make_access_token(&claims.sub, &secret)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `DELETE /api/account` — permanently delete the authenticated user's account.
|
||||
///
|
||||
/// All related rows are removed via `ON DELETE CASCADE` in the schema.
|
||||
pub async fn delete_account(
|
||||
State(pool): State<SqlitePool>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
Reference in New Issue
Block a user