a218999243
Test / test (pull_request) Failing after 17m26s
Closes the three actionable server Low findings from the 2026-07-06 review: - #139: unknown-username logins now verify against a static bcrypt dummy hash so both failure paths pay the same cost — response timing no longer reveals which usernames exist. - #140: register maps a unique-constraint violation to UsernameTaken (409) — the SELECT pre-check stays as the friendly fast path, the constraint is the arbiter for the concurrent case. - #141: avatar uploads must start with the magic bytes of the declared image type; the stored extension (which decides how the file is re-served) is now backed by content, not the Content-Type header. Unit tests: real/spoofed/truncated signatures for all four formats, and the dummy hash's validity at BCRYPT_COST. Closes #139 Closes #140 Closes #141 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
687 lines
25 KiB
Rust
687 lines
25 KiB
Rust
//! Authentication handlers: register, login, refresh, delete account,
|
||
//! current-user profile, and avatar upload.
|
||
|
||
use axum::{Json, body::Bytes, extract::State, http::HeaderMap};
|
||
use bcrypt::{hash, verify};
|
||
use chrono::Utc;
|
||
use jsonwebtoken::{EncodingKey, Header, encode};
|
||
use serde::{Deserialize, Serialize};
|
||
use uuid::Uuid;
|
||
|
||
use crate::{
|
||
AppState,
|
||
error::AppError,
|
||
middleware::{AuthenticatedUser, Claims, validate_refresh_token},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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,
|
||
}
|
||
|
||
/// Response for `GET /api/me`.
|
||
#[derive(Debug, Serialize)]
|
||
pub struct MeResponse {
|
||
pub id: String,
|
||
pub username: String,
|
||
pub avatar_url: Option<String>,
|
||
}
|
||
|
||
/// Successful refresh response — contains the new access token and the rotated
|
||
/// refresh token. The refresh token is always rotated: the client must store
|
||
/// the new value and discard the old one.
|
||
#[derive(Debug, Serialize)]
|
||
pub struct RefreshResponse {
|
||
pub access_token: String,
|
||
pub refresh_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 work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
|
||
pub const BCRYPT_COST: u32 = 12;
|
||
|
||
/// Static bcrypt hash used to equalise login timing when the username does
|
||
/// not exist (issue #139: user-enumeration timing oracle). Both login paths
|
||
/// must pay the same bcrypt cost; this hash is verified against when there
|
||
/// is no real one. Computed once at first use with the same [`BCRYPT_COST`]
|
||
/// as real hashes. `None` only if bcrypt itself fails on a constant input —
|
||
/// in that case the dummy verify is skipped rather than panicking.
|
||
static DUMMY_PASSWORD_HASH: std::sync::LazyLock<Option<String>> =
|
||
std::sync::LazyLock::new(|| hash("ferrous-dummy-timing-pad", BCRYPT_COST).ok());
|
||
|
||
async fn hash_password(password: String) -> Result<String, AppError> {
|
||
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
|
||
.await
|
||
.map_err(|e| AppError::Internal(format!("password hash task failed: {e}")))?
|
||
.map_err(AppError::from)
|
||
}
|
||
|
||
async fn verify_password(password: String, password_hash: String) -> Result<bool, AppError> {
|
||
tokio::task::spawn_blocking(move || verify(password, &password_hash))
|
||
.await
|
||
.map_err(|e| AppError::Internal(format!("password verify task failed: {e}")))?
|
||
.map_err(AppError::from)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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(),
|
||
jti: None,
|
||
};
|
||
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`.
|
||
///
|
||
/// Returns `(jwt_string, jti)`. The caller must insert the jti into
|
||
/// `refresh_tokens` before returning the JWT to the client.
|
||
pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<(String, String), AppError> {
|
||
let jti = Uuid::new_v4().to_string();
|
||
let exp = (Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
|
||
let claims = Claims {
|
||
sub: user_id.to_string(),
|
||
exp,
|
||
kind: "refresh".to_string(),
|
||
jti: Some(jti.clone()),
|
||
};
|
||
let token = encode(
|
||
&Header::default(),
|
||
&claims,
|
||
&EncodingKey::from_secret(secret.as_bytes()),
|
||
)
|
||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||
Ok((token, jti))
|
||
}
|
||
|
||
/// Insert a jti row into `refresh_tokens`. Must be called immediately after
|
||
/// [`make_refresh_token`] and before the token is sent to the client.
|
||
async fn store_refresh_jti(
|
||
pool: &sqlx::SqlitePool,
|
||
jti: &str,
|
||
user_id: &str,
|
||
) -> Result<(), AppError> {
|
||
let expires_at = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||
sqlx::query!(
|
||
"INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||
jti,
|
||
user_id,
|
||
expires_at
|
||
)
|
||
.execute(pool)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `POST /api/auth/register` — create a new account and return tokens.
|
||
/// Minimum and maximum allowed username lengths.
|
||
const USERNAME_MIN: usize = 3;
|
||
const USERNAME_MAX: usize = 32;
|
||
/// Minimum password length.
|
||
pub const PASSWORD_MIN: usize = 8;
|
||
|
||
/// Returns `true` if every character in `s` is ASCII alphanumeric or `_`.
|
||
fn username_chars_ok(s: &str) -> bool {
|
||
s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||
}
|
||
|
||
pub async fn register(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<AuthRequest>,
|
||
) -> Result<Json<AuthResponse>, AppError> {
|
||
// Validate username: 3–32 characters, alphanumeric + underscores only.
|
||
let trimmed = body.username.trim();
|
||
if trimmed.len() < USERNAME_MIN || trimmed.len() > USERNAME_MAX {
|
||
return Err(AppError::BadRequest(format!(
|
||
"username must be {USERNAME_MIN}–{USERNAME_MAX} characters"
|
||
)));
|
||
}
|
||
if !username_chars_ok(trimmed) {
|
||
return Err(AppError::BadRequest(
|
||
"username may only contain letters, digits, and underscores".into(),
|
||
));
|
||
}
|
||
// Validate password: minimum 8 characters.
|
||
if body.password.len() < PASSWORD_MIN {
|
||
return Err(AppError::BadRequest(format!(
|
||
"password must be at least {PASSWORD_MIN} characters"
|
||
)));
|
||
}
|
||
|
||
let username = trimmed.to_string();
|
||
|
||
// 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 = ?", username)
|
||
.fetch_optional(&state.pool)
|
||
.await?
|
||
.flatten();
|
||
|
||
if existing.is_some() {
|
||
tracing::warn!(username = %username, "register: username already taken");
|
||
return Err(AppError::UsernameTaken);
|
||
}
|
||
|
||
let user_id = Uuid::new_v4().to_string();
|
||
let password_hash = hash_password(body.password).await?;
|
||
let now = Utc::now().to_rfc3339();
|
||
|
||
// The SELECT above is a friendly fast path; the UNIQUE constraint is the
|
||
// real arbiter. A concurrent registration that slips between the two
|
||
// surfaces as a unique violation here — map it to the same 409 the
|
||
// fast path produces instead of a raw 500 (issue #140).
|
||
sqlx::query!(
|
||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||
user_id,
|
||
username,
|
||
password_hash,
|
||
now
|
||
)
|
||
.execute(&state.pool)
|
||
.await
|
||
.map_err(|e| match &e {
|
||
sqlx::Error::Database(db) if db.is_unique_violation() => AppError::UsernameTaken,
|
||
_ => AppError::from(e),
|
||
})?;
|
||
|
||
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
|
||
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
|
||
store_refresh_jti(&state.pool, &refresh_jti, &user_id).await?;
|
||
|
||
Ok(Json(AuthResponse {
|
||
access_token,
|
||
refresh_token,
|
||
}))
|
||
}
|
||
|
||
/// `POST /api/auth/login` — verify credentials and return tokens.
|
||
pub async fn login(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<AuthRequest>,
|
||
) -> Result<Json<AuthResponse>, AppError> {
|
||
let username = body.username.trim().to_string();
|
||
let row = sqlx::query_as!(
|
||
UserRow,
|
||
"SELECT id, password_hash FROM users WHERE username = ?",
|
||
username
|
||
)
|
||
.fetch_optional(&state.pool)
|
||
.await?;
|
||
|
||
let Some(row) = row else {
|
||
// Unknown username: burn a bcrypt verify against a static dummy hash
|
||
// so this path costs the same as a wrong-password attempt. Returning
|
||
// immediately here would let response timing reveal which usernames
|
||
// exist (issue #139).
|
||
if let Some(dummy) = DUMMY_PASSWORD_HASH.as_ref() {
|
||
let _ = verify_password(body.password, dummy.clone()).await;
|
||
}
|
||
return Err(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_password(body.password, row_hash).await?;
|
||
if !valid {
|
||
tracing::warn!(username = %username, "login: invalid password");
|
||
return Err(AppError::InvalidCredentials);
|
||
}
|
||
|
||
let access_token = make_access_token(&row_id, &state.jwt_secret)?;
|
||
let (refresh_token, refresh_jti) = make_refresh_token(&row_id, &state.jwt_secret)?;
|
||
store_refresh_jti(&state.pool, &refresh_jti, &row_id).await?;
|
||
|
||
Ok(Json(AuthResponse {
|
||
access_token,
|
||
refresh_token,
|
||
}))
|
||
}
|
||
|
||
/// `POST /api/auth/refresh` — exchange a valid refresh token for a new token pair.
|
||
///
|
||
/// The incoming refresh token is consumed (its jti row is deleted) and a new
|
||
/// refresh token is issued. Using a consumed token returns 401. Tokens issued
|
||
/// before rotation was enabled (no `jti` claim) are also rejected with 401 —
|
||
/// the player must re-login once after upgrading the server.
|
||
///
|
||
/// Expired rows from other sessions are pruned on each successful call.
|
||
pub async fn refresh(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<RefreshRequest>,
|
||
) -> Result<Json<RefreshResponse>, AppError> {
|
||
let claims = validate_refresh_token(&body.refresh_token, &state.jwt_secret)?;
|
||
|
||
// Tokens without jti predate rotation — require re-login.
|
||
let jti = claims.jti.ok_or(AppError::Unauthorized)?;
|
||
|
||
// Consume the old token before issuing new ones, gating on the DELETE
|
||
// actually removing a row. rows_affected == 0 covers both "jti never
|
||
// existed / account deleted" and "a concurrent refresh already consumed
|
||
// it" — the previous SELECT-then-DELETE let two concurrent refreshes
|
||
// both pass the check and both mint fresh token pairs. The DELETE is
|
||
// the mutex: whoever removes the row wins; everyone else gets 401.
|
||
// If the insert below fails, the user loses this session (must
|
||
// re-login) — safe by design.
|
||
let deleted = sqlx::query!("DELETE FROM refresh_tokens WHERE jti = ?", jti)
|
||
.execute(&state.pool)
|
||
.await?;
|
||
if deleted.rows_affected() != 1 {
|
||
return Err(AppError::Unauthorized);
|
||
}
|
||
|
||
let new_access = make_access_token(&claims.sub, &state.jwt_secret)?;
|
||
let (new_refresh, new_jti) = make_refresh_token(&claims.sub, &state.jwt_secret)?;
|
||
store_refresh_jti(&state.pool, &new_jti, &claims.sub).await?;
|
||
|
||
// Prune expired rows from all sessions on each successful rotation.
|
||
// The expires_at index makes this a cheap index-backed scan.
|
||
let now = Utc::now().to_rfc3339();
|
||
sqlx::query!("DELETE FROM refresh_tokens WHERE expires_at < ?", now)
|
||
.execute(&state.pool)
|
||
.await?;
|
||
|
||
Ok(Json(RefreshResponse {
|
||
access_token: new_access,
|
||
refresh_token: new_refresh,
|
||
}))
|
||
}
|
||
|
||
/// `DELETE /api/account` — permanently delete the authenticated user's account.
|
||
///
|
||
/// All related rows (sync_state, refresh_tokens, leaderboard) are removed
|
||
/// via `ON DELETE CASCADE` in the schema.
|
||
pub async fn delete_account(
|
||
State(state): State<AppState>,
|
||
user: AuthenticatedUser,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
sqlx::query!("DELETE FROM users WHERE id = ?", user.user_id)
|
||
.execute(&state.pool)
|
||
.await?;
|
||
|
||
Ok(Json(serde_json::json!({ "ok": true })))
|
||
}
|
||
|
||
/// `GET /api/me` — return the authenticated user's id, username, and avatar URL.
|
||
pub async fn get_me(
|
||
State(state): State<AppState>,
|
||
user: AuthenticatedUser,
|
||
) -> Result<Json<MeResponse>, AppError> {
|
||
struct Row {
|
||
username: Option<String>,
|
||
avatar_url: Option<String>,
|
||
}
|
||
let row = sqlx::query_as!(
|
||
Row,
|
||
"SELECT username, avatar_url FROM users WHERE id = ?",
|
||
user.user_id
|
||
)
|
||
.fetch_optional(&state.pool)
|
||
.await?
|
||
.ok_or_else(|| AppError::NotFound("user not found".into()))?;
|
||
|
||
Ok(Json(MeResponse {
|
||
id: user.user_id,
|
||
username: row.username.ok_or(AppError::Unauthorized)?,
|
||
avatar_url: row.avatar_url,
|
||
}))
|
||
}
|
||
|
||
/// Maximum avatar upload size in bytes (1 MB).
|
||
const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
||
|
||
/// `PUT /api/me/avatar` — upload a new avatar image (raw bytes, ≤ 1 MB).
|
||
///
|
||
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
|
||
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
|
||
/// Returns `true` when `body` begins with the magic bytes of the image type
|
||
/// implied by `ext` (the extension derived from the declared Content-Type).
|
||
///
|
||
/// Avatars are re-served under the stored extension, so the extension must be
|
||
/// derived from the content itself — a client header is not evidence of what
|
||
/// the bytes are (issue #141).
|
||
fn magic_bytes_match(ext: &str, body: &[u8]) -> bool {
|
||
match ext {
|
||
"png" => body.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
|
||
"jpg" => body.starts_with(&[0xFF, 0xD8, 0xFF]),
|
||
"gif" => body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a"),
|
||
"webp" => body.len() >= 12 && body.starts_with(b"RIFF") && &body[8..12] == b"WEBP",
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
pub async fn upload_avatar(
|
||
State(state): State<AppState>,
|
||
user: AuthenticatedUser,
|
||
headers: HeaderMap,
|
||
body: Bytes,
|
||
) -> Result<Json<MeResponse>, AppError> {
|
||
let mime = headers
|
||
.get("content-type")
|
||
.and_then(|v| v.to_str().ok())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
let ext = match mime.as_str() {
|
||
"image/jpeg" | "image/jpg" => "jpg",
|
||
"image/png" => "png",
|
||
"image/webp" => "webp",
|
||
"image/gif" => "gif",
|
||
_ => {
|
||
return Err(AppError::BadRequest(
|
||
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
|
||
));
|
||
}
|
||
};
|
||
if body.len() > AVATAR_MAX_BYTES {
|
||
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||
}
|
||
// The stored extension decides how the file is re-served, so it must be
|
||
// backed by the bytes, not just the client's Content-Type header
|
||
// (issue #141).
|
||
if !magic_bytes_match(ext, &body) {
|
||
return Err(AppError::BadRequest(
|
||
"avatar bytes do not match the declared image type".into(),
|
||
));
|
||
}
|
||
|
||
// Write to avatars/ directory, replacing any previous file for this user.
|
||
tokio::fs::create_dir_all("avatars")
|
||
.await
|
||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||
let filename = format!("{}.{}", user.user_id, ext);
|
||
let path = std::path::Path::new("avatars").join(&filename);
|
||
let tmp_path = std::path::Path::new("avatars").join(format!("{}.{}.tmp", user.user_id, ext));
|
||
// Write to a temp file then atomically rename so concurrent readers never
|
||
// see a partially-written avatar.
|
||
tokio::fs::write(&tmp_path, &body)
|
||
.await
|
||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||
if let Err(e) = tokio::fs::rename(&tmp_path, &path).await {
|
||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||
return Err(AppError::Internal(e.to_string()));
|
||
}
|
||
// Remove stale files with other extensions after the atomic rename.
|
||
for old_ext in &["jpg", "png", "webp", "gif"] {
|
||
if *old_ext != ext {
|
||
let _ = tokio::fs::remove_file(
|
||
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
let avatar_url = format!("/avatars/{filename}");
|
||
sqlx::query!(
|
||
"UPDATE users SET avatar_url = ? WHERE id = ?",
|
||
avatar_url,
|
||
user.user_id
|
||
)
|
||
.execute(&state.pool)
|
||
.await?;
|
||
|
||
let username: Option<String> =
|
||
sqlx::query_scalar!("SELECT username FROM users WHERE id = ?", user.user_id)
|
||
.fetch_optional(&state.pool)
|
||
.await?;
|
||
|
||
Ok(Json(MeResponse {
|
||
id: user.user_id,
|
||
username: username.ok_or(AppError::Unauthorized)?,
|
||
avatar_url: Some(avatar_url),
|
||
}))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Admin helpers (CLI use only — not exposed via HTTP)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Reset the password for `username` to `new_password`.
|
||
///
|
||
/// On success:
|
||
/// - The `password_hash` column in `users` is overwritten with a fresh bcrypt
|
||
/// hash of `new_password`.
|
||
/// - **All** active refresh tokens for the user are deleted, forcing every
|
||
/// existing session to re-authenticate before it can issue new access tokens.
|
||
///
|
||
/// Returns `AppError::NotFound` when no account with `username` exists.
|
||
/// Returns `AppError::BadRequest` when `new_password` is shorter than
|
||
/// [`PASSWORD_MIN`].
|
||
pub async fn reset_password(
|
||
pool: &sqlx::SqlitePool,
|
||
username: &str,
|
||
new_password: &str,
|
||
) -> Result<(), AppError> {
|
||
if new_password.len() < PASSWORD_MIN {
|
||
return Err(AppError::BadRequest(format!(
|
||
"password must be at least {PASSWORD_MIN} characters"
|
||
)));
|
||
}
|
||
|
||
let user_id: Option<String> =
|
||
sqlx::query_scalar!("SELECT id FROM users WHERE username = ?", username)
|
||
.fetch_optional(pool)
|
||
.await?
|
||
.flatten();
|
||
|
||
let user_id =
|
||
user_id.ok_or_else(|| AppError::NotFound(format!("user '{username}' not found")))?;
|
||
|
||
let new_hash = hash_password(new_password.to_string()).await?;
|
||
|
||
sqlx::query!(
|
||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||
new_hash,
|
||
user_id
|
||
)
|
||
.execute(pool)
|
||
.await?;
|
||
|
||
// Invalidate all active sessions — the user must log in again with the
|
||
// new password before refresh tokens work.
|
||
sqlx::query!("DELETE FROM refresh_tokens WHERE user_id = ?", user_id)
|
||
.execute(pool)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||
|
||
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
|
||
|
||
fn decode_claims(token: &str) -> Claims {
|
||
let mut validation = Validation::default();
|
||
validation.leeway = 60;
|
||
decode::<Claims>(
|
||
token,
|
||
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||
&validation,
|
||
)
|
||
.unwrap()
|
||
.claims
|
||
}
|
||
|
||
#[test]
|
||
fn make_access_token_decodes_with_correct_claims() {
|
||
let token = make_access_token("user-123", TEST_SECRET).unwrap();
|
||
let claims = decode_claims(&token);
|
||
assert_eq!(claims.sub, "user-123");
|
||
assert_eq!(claims.kind, "access");
|
||
assert!(claims.jti.is_none(), "access token must not carry a jti");
|
||
let now = Utc::now().timestamp() as usize;
|
||
assert!(claims.exp > now + 86_400 - 60);
|
||
assert!(claims.exp < now + 86_400 + 60);
|
||
}
|
||
|
||
#[test]
|
||
fn make_refresh_token_decodes_with_correct_claims() {
|
||
let (token, jti) = make_refresh_token("user-456", TEST_SECRET).unwrap();
|
||
let claims = decode_claims(&token);
|
||
assert_eq!(claims.sub, "user-456");
|
||
assert_eq!(claims.kind, "refresh");
|
||
assert_eq!(
|
||
claims.jti.as_deref(),
|
||
Some(jti.as_str()),
|
||
"jti in JWT must match returned jti"
|
||
);
|
||
assert!(!jti.is_empty(), "jti must be non-empty");
|
||
let now = Utc::now().timestamp() as usize;
|
||
assert!(claims.exp > now + 30 * 86_400 - 60);
|
||
assert!(claims.exp < now + 30 * 86_400 + 60);
|
||
}
|
||
|
||
#[test]
|
||
fn make_refresh_token_generates_unique_jtis() {
|
||
let (_, jti1) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||
let (_, jti2) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||
assert_ne!(jti1, jti2, "each call must produce a unique jti");
|
||
}
|
||
|
||
#[test]
|
||
fn make_access_token_wrong_secret_fails_decode() {
|
||
let token = make_access_token("user-789", TEST_SECRET).unwrap();
|
||
let result = decode::<Claims>(
|
||
&token,
|
||
&DecodingKey::from_secret(b"wrong_secret"),
|
||
&Validation::default(),
|
||
);
|
||
assert!(result.is_err(), "decoding with wrong secret must fail");
|
||
}
|
||
|
||
#[test]
|
||
fn access_and_refresh_tokens_have_different_kinds() {
|
||
let access = make_access_token("u", TEST_SECRET).unwrap();
|
||
let (refresh, _jti) = make_refresh_token("u", TEST_SECRET).unwrap();
|
||
let a_claims = decode_claims(&access);
|
||
let r_claims = decode_claims(&refresh);
|
||
assert_ne!(a_claims.kind, r_claims.kind);
|
||
}
|
||
|
||
#[test]
|
||
fn username_chars_ok_accepts_alphanumeric_and_underscore() {
|
||
assert!(username_chars_ok("alice"));
|
||
assert!(username_chars_ok("Alice_123"));
|
||
assert!(username_chars_ok("UPPER_case_99"));
|
||
}
|
||
|
||
#[test]
|
||
fn username_chars_ok_rejects_special_chars() {
|
||
assert!(!username_chars_ok("ali ce")); // space
|
||
assert!(!username_chars_ok("ali-ce")); // hyphen
|
||
assert!(!username_chars_ok("ali.ce")); // dot
|
||
assert!(!username_chars_ok("ali@ce")); // at
|
||
assert!(!username_chars_ok("ali!ce")); // exclamation
|
||
}
|
||
|
||
#[test]
|
||
fn username_chars_ok_accepts_empty_string() {
|
||
// The length check in `register` guards against empty usernames;
|
||
// this function only validates characters, so empty is technically ok.
|
||
assert!(username_chars_ok(""));
|
||
}
|
||
|
||
#[test]
|
||
fn magic_bytes_match_accepts_real_signatures() {
|
||
assert!(magic_bytes_match(
|
||
"png",
|
||
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00]
|
||
));
|
||
assert!(magic_bytes_match("jpg", &[0xFF, 0xD8, 0xFF, 0xE0, 0x00]));
|
||
assert!(magic_bytes_match("gif", b"GIF89a\x00\x00"));
|
||
assert!(magic_bytes_match("gif", b"GIF87a\x00\x00"));
|
||
assert!(magic_bytes_match("webp", b"RIFF\x00\x00\x00\x00WEBPVP8 "));
|
||
}
|
||
|
||
#[test]
|
||
fn magic_bytes_match_rejects_mismatched_or_bogus_content() {
|
||
// HTML declared as PNG — the exact spoof the check exists to stop.
|
||
assert!(!magic_bytes_match("png", b"<html><script>"));
|
||
// Real PNG bytes declared as JPEG: extension must match the bytes.
|
||
assert!(!magic_bytes_match(
|
||
"jpg",
|
||
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
|
||
));
|
||
// Truncated / empty bodies never match.
|
||
assert!(!magic_bytes_match("png", &[0x89, b'P']));
|
||
assert!(!magic_bytes_match("webp", b"RIFF1234WEB"));
|
||
assert!(!magic_bytes_match("gif", b""));
|
||
// Unknown extensions are never accepted.
|
||
assert!(!magic_bytes_match("svg", b"<svg xmlns="));
|
||
}
|
||
|
||
#[test]
|
||
fn dummy_password_hash_is_available_and_verifiable() {
|
||
// The login timing pad (issue #139) must be a real bcrypt hash so the
|
||
// unknown-user path pays a genuine verify at BCRYPT_COST.
|
||
let dummy = DUMMY_PASSWORD_HASH
|
||
.as_ref()
|
||
.expect("bcrypt of a constant input must succeed");
|
||
assert!(verify("ferrous-dummy-timing-pad", dummy).unwrap_or(false));
|
||
assert!(!verify("wrong-password", dummy).unwrap_or(true));
|
||
}
|
||
|
||
#[test]
|
||
fn username_chars_ok_rejects_unicode_letters() {
|
||
// Non-ASCII characters must be rejected even if they look like letters.
|
||
assert!(!username_chars_ok("héro"));
|
||
assert!(!username_chars_ok("用户"));
|
||
}
|
||
}
|