Files
wt cf25e28c28
Rust verification / test (push) Successful in 4m10s
initial commit
2026-10-28 15:59:54 +07:00

1575 lines
55 KiB
Rust

//! Browser/native sessions, legacy Laravel import and Passport authentication.
use crate::AppState;
use axum::{
Json, Router,
body::to_bytes,
extract::{ConnectInfo, Request, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
routing::{get, post},
};
use rand::{RngCore, rngs::OsRng};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use sqlx::{Row, SqlitePool};
use std::{
net::SocketAddr,
time::{SystemTime, UNIX_EPOCH},
};
use subtle::ConstantTimeEq;
const COOKIE: &str = "rust_2fauth_session";
pub(crate) const TTL: i64 = 90 * 24 * 3600;
fn csrf_status() -> StatusCode {
StatusCode::from_u16(419).expect("valid Laravel CSRF status")
}
#[derive(Clone, Debug)]
pub struct AuthUser {
pub id: i64,
pub is_admin: bool,
}
pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::raw_sql("CREATE TABLE IF NOT EXISTS rust_sessions (token_hash TEXT PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, csrf_hash TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS rust_sessions_expiry ON rust_sessions(expires_at); CREATE TABLE IF NOT EXISTS rust_login_attempts (key TEXT PRIMARY KEY, window INTEGER NOT NULL, attempts INTEGER NOT NULL);").execute(db).await?;
crate::api_limits::init(db).await?;
Ok(())
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/refresh-csrf", get(refresh_csrf))
.route("/user/login", post(login))
.route("/user/logout", get(logout).post(logout))
}
pub(crate) fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
pub(crate) fn random() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub(crate) fn digest(s: &str) -> String {
format!("{:x}", Sha256::digest(s.as_bytes()))
}
fn token(headers: &HeaderMap) -> Option<String> {
// Reject ambiguity rather than accepting one of multiple session cookies.
let mut found = None;
for cookie in headers.get_all(header::COOKIE) {
for part in cookie.to_str().ok()?.split(';') {
if let Some((key, value)) = part.trim().split_once('=')
&& key == COOKIE
{
if found.is_some()
|| value.len() != 64
|| !value.bytes().all(|x| x.is_ascii_hexdigit())
{
return None;
}
found = Some(value.to_owned());
}
}
}
found
}
fn secure() -> bool {
std::env::var("RUST_COOKIE_SECURE").as_deref() != Ok("false")
}
pub(crate) fn cookies(response: &mut Response, session: &str, csrf: &str, age: i64) {
let suffix = if secure() { "; Secure" } else { "" };
for cookie in [
format!("{COOKIE}={session}; Path=/; HttpOnly; SameSite=Strict; Max-Age={age}{suffix}"),
format!("XSRF-TOKEN={csrf}; Path=/; SameSite=Strict; Max-Age={age}{suffix}"),
] {
if let Ok(v) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(header::SET_COOKIE, v);
}
}
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
pub(crate) fn clear_legacy_cookie(response: &mut Response) {
let suffix = if secure() { "; Secure" } else { "" };
if let Ok(value) = HeaderValue::from_str(&format!(
"laravel_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{suffix}"
)) {
response.headers_mut().append(header::SET_COOKIE, value);
}
}
fn error(status: StatusCode, message: &str) -> Response {
(status, Json(json!({"message":message}))).into_response()
}
pub(crate) fn csrf_valid(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("x-csrf-token")
.or_else(|| headers.get("x-xsrf-token"))
.and_then(|v| v.to_str().ok())
.filter(|v| v.len() == 64)
.is_some_and(|v| bool::from(digest(v).as_bytes().ct_eq(expected.as_bytes())))
}
pub(crate) async fn session(
db: &SqlitePool,
headers: &HeaderMap,
) -> Result<Option<sqlx::sqlite::SqliteRow>, sqlx::Error> {
let Some(value) = token(headers) else {
return Ok(None);
};
sqlx::query("SELECT s.token_hash,s.csrf_hash,s.user_id,u.is_admin FROM rust_sessions s LEFT JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?")
.bind(digest(&value)).bind(now()).fetch_optional(db).await
}
/// Browser guards never authenticate an Authorization bearer token.
pub(crate) async fn browser_user(
state: &AppState,
headers: &HeaderMap,
) -> Result<Option<AuthUser>, crate::ApiError> {
if let Some(row) = session(&state.db, headers).await?
&& let (Some(id), Some(is_admin)) = (
row.try_get::<Option<i64>, _>("user_id")?,
row.try_get::<Option<bool>, _>("is_admin")?,
)
{
return Ok(Some(AuthUser { id, is_admin }));
}
if let Some(legacy) = crate::laravel_sessions::authenticate(state, headers).await? {
return Ok(legacy.user);
}
Ok(None)
}
fn named_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let mut found = None;
for h in headers.get_all(header::COOKIE) {
for part in h.to_str().ok()?.split(';') {
if let Some((key, value)) = part.trim().split_once('=')
&& key == name
{
if found.is_some() {
return None;
}
found = Some(value.to_owned());
}
}
}
found
}
pub(crate) async fn browser_csrf_token(
state: &AppState,
headers: &HeaderMap,
) -> Result<Option<String>, crate::ApiError> {
if let Some(row) = session(&state.db, headers).await?
&& let Some(token) = named_cookie(headers, "XSRF-TOKEN")
{
let value = percent_encoding::percent_decode_str(&token)
.decode_utf8()
.ok()
.map(|v| v.into_owned());
if let Some(value) = value
&& bool::from(
digest(&value)
.as_bytes()
.ct_eq(row.get::<String, _>("csrf_hash").as_bytes()),
)
{
return Ok(Some(value));
}
}
Ok(crate::laravel_sessions::authenticate(state, headers)
.await?
.map(|v| v.csrf))
}
pub(crate) async fn browser_session_id(
state: &AppState,
headers: &HeaderMap,
) -> Result<Option<String>, crate::ApiError> {
if let Some(row) = session(&state.db, headers).await? {
return Ok(Some(row.try_get("token_hash")?));
}
if let Some(legacy) = crate::laravel_sessions::authenticate(state, headers).await?
&& let Some(id) = legacy.id
{
return Ok(Some(digest(&format!("laravel-session:{id}"))));
}
// A recaller identifies an account, not a unique browser session.
// The normal HTML bootstrap must first import it into a fresh native SID.
Ok(None)
}
pub(crate) async fn invalidate_browser(
state: &AppState,
headers: &HeaderMap,
) -> Result<(), crate::ApiError> {
let mut users = std::collections::BTreeSet::new();
if let Some(row) = session(&state.db, headers).await? {
if let Some(user) = row.try_get::<Option<i64>, _>("user_id")? {
users.insert(user);
}
sqlx::query("DELETE FROM rust_sessions WHERE token_hash=?")
.bind(row.try_get::<String, _>("token_hash")?)
.execute(&state.db)
.await?;
}
if let Some(legacy) = crate::laravel_sessions::authenticate(state, headers).await? {
if let Some(user) = legacy.user.as_ref() {
users.insert(user.id);
}
crate::laravel_sessions::destroy(state, &legacy).await?;
}
for user in users {
crate::laravel_sessions::revoke_remember(state, user).await?;
}
Ok(())
}
pub(crate) fn clear_laravel_session_cookies(response: &mut Response) {
let Ok(names) = crate::laravel_sessions::cookie_names() else {
return;
};
let path = std::env::var("SESSION_PATH").unwrap_or_else(|_| "/".into());
let domain = std::env::var("SESSION_DOMAIN")
.ok()
.filter(|v| !v.is_empty() && v != "null");
for name in names {
let value = format!(
"{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0{}{}",
domain
.as_ref()
.map(|d| format!("; Domain={d}"))
.unwrap_or_default(),
if secure() { "; Secure" } else { "" }
);
if let Ok(value) = HeaderValue::from_str(&value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
}
}
/// Consent/client administration uses browser state, never an API bearer.
pub(crate) async fn require_web_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
let mutation = !matches!(
*request.method(),
Method::GET | Method::HEAD | Method::OPTIONS
);
if mutation {
let valid = match session(&state.db, request.headers()).await {
Ok(Some(row)) => csrf_valid(request.headers(), row.get("csrf_hash")),
Ok(None) => {
match crate::laravel_sessions::authenticate(&state, request.headers()).await {
Ok(Some(legacy)) => {
crate::laravel_sessions::csrf_valid(&state, request.headers(), &legacy)
}
_ => false,
}
}
Err(_) => false,
};
if !valid {
let expected = match browser_csrf_token(&state, request.headers()).await {
Ok(v) => v,
Err(e) => return e.into_response(),
};
let (parts, body) = request.into_parts();
let bytes = match to_bytes(body, 64 * 1024).await {
Ok(bytes) => bytes,
Err(_) => return error(StatusCode::PAYLOAD_TOO_LARGE, "Request body too large"),
};
let supplied = if parts
.headers
.get(header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.is_some_and(|h| h.starts_with("application/x-www-form-urlencoded"))
{
serde_urlencoded::from_bytes::<std::collections::HashMap<String, String>>(&bytes)
.ok()
.and_then(|mut v| v.remove("_token"))
} else {
serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.get("_token").and_then(|v| v.as_str()).map(str::to_owned))
};
let token_valid = if let Some(supplied) = supplied {
if let Some(expected) = expected {
bool::from(expected.as_bytes().ct_eq(supplied.as_bytes()))
} else {
match session(&state.db, &parts.headers).await {
Ok(Some(row)) => bool::from(
digest(&supplied)
.as_bytes()
.ct_eq(row.get::<String, _>("csrf_hash").as_bytes()),
),
_ => false,
}
}
} else {
false
};
if !token_valid {
return error(csrf_status(), "CSRF token mismatch.");
}
request = Request::from_parts(parts, axum::body::Body::from(bytes));
}
}
let user = match browser_user(&state, request.headers()).await {
Ok(Some(user)) => user,
Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."),
Err(e) => return e.into_response(),
};
match active(&state, user.id).await {
Ok(true) => (),
Ok(false) => return error(StatusCode::IM_A_TEAPOT, "inactivity detected"),
Err(e) => return e.into_response(),
}
if let Ok(Some(legacy)) = crate::laravel_sessions::authenticate(&state, request.headers()).await
&& let Err(e) = crate::laravel_sessions::touch(&state, &legacy).await
{
return e.into_response();
}
request.extensions_mut().insert(user);
next.run(request).await
}
pub async fn require_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
if crate::proxy_auth::enabled() {
if crate::proxy_auth::blocked(request.method(), request.uri().path()) {
return crate::proxy_auth::blocked_error().into_response();
}
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|p| p.0);
match crate::proxy_auth::authenticate(&state, request.headers(), peer).await {
Ok(Some(user)) => {
request.extensions_mut().insert(user);
return next.run(request).await;
}
Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."),
Err(e) => return e.into_response(),
}
}
if request.headers().contains_key(header::AUTHORIZATION) {
match crate::passport::authenticate(&state.db, request.headers()).await {
Ok(Some(identity)) => {
request.extensions_mut().insert(identity.user.clone());
request.extensions_mut().insert(identity);
return next.run(request).await;
}
Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."),
Err(e) => return e.into_response(),
}
}
let original = request
.extensions()
.get::<axum::extract::OriginalUri>()
.map(|v| v.0.path())
.unwrap_or(request.uri().path());
let api = original.contains("/api/v1/") || original.ends_with("/api/v1");
if !api
&& session(&state.db, request.headers())
.await
.ok()
.flatten()
.and_then(|r| r.get::<Option<i64>, _>("user_id"))
.is_none()
{
match crate::laravel_sessions::authenticate(&state, request.headers()).await {
Ok(Some(legacy)) if legacy.user.is_some() => {
if !matches!(
*request.method(),
Method::GET | Method::HEAD | Method::OPTIONS
) && !crate::laravel_sessions::csrf_valid(&state, request.headers(), &legacy)
{
return error(csrf_status(), "CSRF token mismatch");
}
let user = legacy.user.clone().unwrap();
match active(&state, user.id).await {
Ok(true) => (),
Ok(false) => {
let _ = crate::laravel_sessions::destroy(&state, &legacy).await;
return error(StatusCode::IM_A_TEAPOT, "inactivity detected");
}
Err(e) => return e.into_response(),
}
if let Err(e) = crate::laravel_sessions::touch(&state, &legacy).await {
return e.into_response();
}
request.extensions_mut().insert(user);
return next.run(request).await;
}
Ok(_) => (),
Err(e) => return e.into_response(),
}
}
let native = match session(&state.db, request.headers()).await {
Ok(value) => value,
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
};
// Once imported, the native browser session is authoritative. A fresh
// Passport cookie must not force CSRF headers onto read-only native calls.
if native
.as_ref()
.is_none_or(|row| row.get::<Option<i64>, _>("user_id").is_none())
{
match crate::passport::cookie_authenticate(&state, request.headers()).await {
Ok(Some(user)) => {
match active(&state, user.id).await {
Ok(true) => (),
Ok(false) => return error(StatusCode::IM_A_TEAPOT, "inactivity detected"),
Err(e) => return e.into_response(),
}
request.extensions_mut().insert(user);
return next.run(request).await;
}
Ok(None) => (),
Err(e) => return e.into_response(),
}
}
let Some(row) = native else {
return error(StatusCode::UNAUTHORIZED, "Unauthenticated.");
};
let Some(id) = row.get::<Option<i64>, _>("user_id") else {
return error(StatusCode::UNAUTHORIZED, "Unauthenticated.");
};
let Some(admin) = row.get::<Option<bool>, _>("is_admin") else {
return error(StatusCode::UNAUTHORIZED, "Unauthenticated.");
};
if !matches!(
*request.method(),
Method::GET | Method::HEAD | Method::OPTIONS
) && !csrf_valid(request.headers(), row.get("csrf_hash"))
{
return error(csrf_status(), "CSRF token mismatch");
}
match active(&state, id).await {
Ok(true) => (),
Ok(false) => {
let _ = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=?")
.bind(row.get::<String, _>("token_hash"))
.execute(&state.db)
.await;
return error(StatusCode::IM_A_TEAPOT, "inactivity detected");
}
Err(e) => return e.into_response(),
}
if sqlx::query("UPDATE rust_sessions SET expires_at=? WHERE token_hash=?")
.bind(now() + TTL)
.bind(row.get::<String, _>("token_hash"))
.execute(&state.db)
.await
.is_err()
{
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
request.extensions_mut().insert(AuthUser {
id,
is_admin: admin,
});
let mut response = next.run(request).await;
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
async fn active(state: &AppState, id: i64) -> Result<bool, crate::ApiError> {
let row = sqlx::query("SELECT preferences,last_seen_at FROM users WHERE id=?")
.bind(id)
.fetch_one(&state.db)
.await?;
let raw: Option<String> = row.try_get("preferences")?;
let prefs = crate::settings::preferences_from(raw.as_deref());
let minutes = prefs["kickUserAfter"]
.as_i64()
.or_else(|| prefs["kickUserAfter"].as_str().and_then(|v| v.parse().ok()))
.unwrap_or(0);
let last: Option<String> = row.try_get("last_seen_at")?;
let inactive = last
.as_deref()
.and_then(|v| {
chrono::NaiveDateTime::parse_from_str(v, "%Y-%m-%d %H:%M:%S%.f")
.ok()
.map(|v| v.and_utc().timestamp())
.or_else(|| {
chrono::DateTime::parse_from_rfc3339(v)
.ok()
.map(|v| v.timestamp())
})
})
.is_some_and(|last| minutes > 0 && (now() - last).abs() > minutes.saturating_mul(60));
sqlx::query("UPDATE users SET last_seen_at=CURRENT_TIMESTAMP WHERE id=?")
.bind(id)
.execute(&state.db)
.await?;
Ok(!inactive)
}
pub async fn refresh_csrf(State(state): State<AppState>, headers: HeaderMap) -> Response {
// Bound anonymous bootstrap traffic and discard expired experimental records.
match throttle_limit(&state.db, "csrf-bootstrap-global".into(), 120).await {
Ok(true) => (),
Ok(false) => return error(StatusCode::TOO_MANY_REQUESTS, "Too many session requests"),
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
}
if sqlx::query("DELETE FROM rust_sessions WHERE expires_at<=?")
.bind(now())
.execute(&state.db)
.await
.is_err()
|| sqlx::query("DELETE FROM rust_login_attempts WHERE window<?")
.bind(now() / 60 - 60)
.execute(&state.db)
.await
.is_err()
{
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
// Upgrade a valid PHP browser token before replacing its encrypted XSRF cookie.
let mut legacy_headers = headers.clone();
if let Some(value) = headers
.get_all(header::COOKIE)
.iter()
.filter_map(|h| h.to_str().ok())
.flat_map(|h| h.split(';'))
.find_map(|part| part.trim().strip_prefix("XSRF-TOKEN="))
&& let Ok(value) = HeaderValue::from_str(value)
{
legacy_headers.insert("x-xsrf-token", value);
}
let legacy_session = match crate::laravel_sessions::authenticate(&state, &headers).await {
Ok(v) => v,
Err(e) => return e.into_response(),
};
let legacy_user = legacy_session
.as_ref()
.and_then(|v| v.user.as_ref())
.map(|u| u.id)
.or(
crate::passport::cookie_authenticate(&state, &legacy_headers)
.await
.ok()
.flatten()
.map(|u| u.id),
);
let csrf = random();
let old = match session(&state.db, &headers).await {
Ok(v) => v,
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
};
let (sid, result) = if let Some(ref row) = old
&& row.get::<Option<i64>, _>("user_id").is_none()
&& legacy_user.is_some()
{
// Import is an authentication transition. Never promote an existing
// anonymous SID that could have been planted in another browser.
let sid = random();
let result = async {
let mut tx = state.db.begin().await?;
let deleted = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND user_id IS NULL AND expires_at>?")
.bind(row.get::<String, _>("token_hash")).bind(now()).execute(&mut *tx).await?;
if deleted.rows_affected() != 1 {return Err(sqlx::Error::RowNotFound);}
let inserted = sqlx::query("INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)")
.bind(digest(&sid)).bind(legacy_user).bind(digest(&csrf)).bind(now()+TTL).execute(&mut *tx).await?;
tx.commit().await?;
Ok(inserted)
}.await;
(sid, result)
} else if let Some(row) = old {
let sid = token(&headers).unwrap();
let result =
sqlx::query("UPDATE rust_sessions SET csrf_hash=?,user_id=COALESCE(user_id,?) WHERE token_hash=? AND expires_at>?")
.bind(digest(&csrf))
.bind(legacy_user)
.bind(row.get::<String, _>("token_hash"))
.bind(now())
.execute(&state.db)
.await;
(sid, result)
} else {
let sid = random();
let result = sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES (?,?,?,?)",
)
.bind(digest(&sid))
.bind(legacy_user)
.bind(digest(&csrf))
.bind(now() + TTL)
.execute(&state.db)
.await;
(sid, result)
};
if result.is_err() {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
let mut response = csrf.clone().into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=UTF-8"),
);
cookies(&mut response, &sid, &csrf, TTL);
if legacy_user.is_some() {
if let Some(legacy) = legacy_session.as_ref()
&& let Err(e) = crate::laravel_sessions::destroy(&state, legacy).await
{
return e.into_response();
}
clear_legacy_cookie(&mut response);
clear_laravel_session_cookies(&mut response);
}
response
}
#[derive(Deserialize)]
struct Credentials {
email: String,
password: String,
}
async fn throttle(db: &SqlitePool, key: String) -> Result<bool, sqlx::Error> {
throttle_limit(db, key, 10).await
}
pub(crate) async fn throttle_limit(
db: &SqlitePool,
key: String,
limit: i64,
) -> Result<bool, sqlx::Error> {
let count:i64=sqlx::query_scalar("INSERT INTO rust_login_attempts(key,window,attempts) VALUES (?,?,1) ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN window=excluded.window THEN attempts+1 ELSE 1 END, window=excluded.window RETURNING attempts")
.bind(digest(&key)).bind(now()/60).fetch_one(db).await?;
Ok(count <= limit)
}
pub(crate) fn verify(password: String, hash: String) -> bool {
if hash.starts_with("$2") {
bcrypt::verify(password, &hash).unwrap_or(false)
} else {
use argon2::{PasswordHash, PasswordVerifier};
PasswordHash::new(&hash).ok().is_some_and(|parsed| {
argon2::Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
})
}
}
async fn login(State(state): State<AppState>, request: Request) -> Response {
let (parts, body) = request.into_parts();
let audit_context = crate::auth_audit::Context::from_parts(&parts);
let row = match session(&state.db, &parts.headers).await {
Ok(Some(row)) => row,
Ok(None) => return error(csrf_status(), "Fetch /refresh-csrf first"),
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
};
if !csrf_valid(&parts.headers, row.get("csrf_hash")) {
return error(csrf_status(), "CSRF token mismatch");
}
// ConnectInfo is the direct socket peer. Forwarded and X-Forwarded-For are never trusted.
let ip = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|p| p.0.ip().to_string())
.unwrap_or_else(|| "unknown-peer".into());
match throttle(&state.db, format!("peer:{ip}")).await {
Ok(true) => (),
Ok(false) => return error(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts"),
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
}
let bytes = match to_bytes(body, 16 * 1024).await {
Ok(v) => v,
Err(_) => return error(StatusCode::PAYLOAD_TOO_LARGE, "Invalid login body"),
};
let content = parts
.headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let credentials: Credentials = match if content.starts_with("application/json") {
serde_json::from_slice(&bytes).map_err(|_| ())
} else if content.starts_with("application/x-www-form-urlencoded") {
serde_urlencoded::from_bytes(&bytes).map_err(|_| ())
} else {
Err(())
} {
Ok(v) => v,
Err(_) => {
return error(
StatusCode::UNPROCESSABLE_ENTITY,
"Email and password are required",
);
}
};
if credentials.email.len() > 254
|| credentials.password.len() > 4096
|| credentials.password.is_empty()
{
return error(StatusCode::UNPROCESSABLE_ENTITY, "Invalid credentials");
}
match throttle(
&state.db,
format!("email:{}", credentials.email.to_lowercase()),
)
.await
{
Ok(true) => (),
Ok(false) => return error(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts"),
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
}
let user=match sqlx::query("SELECT id,name,email,password,is_admin,preferences,oauth_provider FROM users WHERE email=?").bind(credentials.email.to_lowercase()).fetch_optional(&state.db).await { Ok(v)=>v,Err(_)=>return error(StatusCode::SERVICE_UNAVAILABLE,"Authentication unavailable") };
// A valid dummy hash ensures unknown accounts still perform password work off the async executor.
let hash = user
.as_ref()
.map(|u| u.get::<String, _>("password"))
.unwrap_or_else(|| {
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6Ttxj9I7gX7dc5BLyO2NLzO5h3Wmi".to_owned()
});
static HASH_SLOTS: std::sync::OnceLock<std::sync::Arc<tokio::sync::Semaphore>> =
std::sync::OnceLock::new();
let slots = HASH_SLOTS
.get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(4)))
.clone();
let permit = match slots.try_acquire_owned() {
Ok(p) => p,
Err(_) => return error(StatusCode::TOO_MANY_REQUESTS, "Authentication busy"),
};
let valid = tokio::task::spawn_blocking(move || {
let _permit = permit;
verify(credentials.password, hash)
})
.await
.unwrap_or(false);
if !valid
&& let Some(user) = user.as_ref()
&& crate::auth_audit::failed(&state, user.get("id"), &audit_context, Some("password"))
.await
.is_err()
{
tracing::error!("Failed-login audit persistence failed");
}
let Some(user) = user.filter(|_| valid) else {
return error(
StatusCode::UNAUTHORIZED,
"These credentials do not match our records",
);
};
let admin: bool = user.get("is_admin");
let raw_preferences = user.get::<Option<String>, _>("preferences");
let preferences = crate::settings::preferences_from(raw_preferences.as_deref());
if preferences["useWebauthnOnly"].as_bool() == Some(true) {
return error(
StatusCode::UNAUTHORIZED,
"These credentials do not match our records",
);
}
let app_settings = match crate::settings::regular_settings(&state.db).await {
Ok(v) => v,
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
};
if app_settings["useSsoOnly"].as_bool() == Some(true) && !admin {
return error(StatusCode::FORBIDDEN, "Password login is disabled");
}
let sid = random();
let csrf = random();
let mut tx = match state.db.begin().await {
Ok(v) => v,
Err(_) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
};
// Delete the old session atomically; concurrent requests using it cannot both log in.
let deleted = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?")
.bind(row.get::<String, _>("token_hash"))
.bind(now())
.execute(&mut *tx)
.await;
if !matches!(deleted,Ok(ref v) if v.rows_affected()==1) {
return error(csrf_status(), "Session expired");
}
if sqlx::query("UPDATE users SET last_seen_at=CURRENT_TIMESTAMP WHERE id=?")
.bind(user.get::<i64, _>("id"))
.execute(&mut *tx)
.await
.is_err()
{
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
if sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES (?,?,?,?)",
)
.bind(digest(&sid))
.bind(user.get::<i64, _>("id"))
.bind(digest(&csrf))
.bind(now() + TTL)
.execute(&mut *tx)
.await
.is_err()
|| tx.commit().await.is_err()
{
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
// The request still carries the old cookies, so the newly issued SID is
// unaffected while stale PHP sessions and recallers are revoked.
if let Err(e) = invalidate_browser(&state, &parts.headers).await {
return e.into_response();
}
if crate::auth_audit::login(&state, user.get("id"), &audit_context, Some("password"))
.await
.is_err()
{
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
let mut response=Json(json!({"message":"authenticated","id":user.get::<i64,_>("id"),"name":user.get::<String,_>("name"),"email":user.get::<String,_>("email"),"is_admin":admin,"preferences":preferences,"oauth_provider":user.get::<Option<String>,_>("oauth_provider"),"appSettings":app_settings})).into_response();
cookies(&mut response, &sid, &csrf, TTL);
clear_legacy_cookie(&mut response);
clear_laravel_session_cookies(&mut response);
response
}
async fn logout(State(state): State<AppState>, request: Request) -> Response {
let context = crate::auth_audit::Context::from_request(&request);
let headers = request.headers();
let user = match browser_user(&state, headers).await {
Ok(Some(user)) => user,
Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."),
Err(e) => return e.into_response(),
};
let csrf = match session(&state.db, headers).await {
Ok(Some(row)) => csrf_valid(headers, row.get("csrf_hash")),
Ok(None) => match crate::laravel_sessions::authenticate(&state, headers).await {
Ok(Some(v)) => crate::laravel_sessions::csrf_valid(&state, headers, &v),
_ => false,
},
Err(_) => false,
};
if !csrf {
return error(csrf_status(), "CSRF token mismatch");
}
if let Err(e) = invalidate_browser(&state, headers).await {
return e.into_response();
}
if crate::auth_audit::logout(&state, user.id, &context, None)
.await
.is_err()
{
return error(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication unavailable",
);
}
let mut response = Json(json!({"message":"signed out"})).into_response();
cookies(&mut response, "", "", 0);
clear_legacy_cookie(&mut response);
clear_laravel_session_cookies(&mut response);
response
}
#[cfg(test)]
mod tests {
// Password handlers deliberately reject overload; keep integration logins
// deterministic instead of competing for the process-wide hash semaphore.
static LOGIN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
use super::*;
#[test]
fn session_cookie_parsing() {
let mut h = HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{COOKIE}={}", "a".repeat(64))).unwrap(),
);
assert_eq!(token(&h), Some("a".repeat(64)));
h.append(
header::COOKIE,
HeaderValue::from_str(&format!("{COOKIE}={}", "b".repeat(64))).unwrap(),
);
assert!(token(&h).is_none());
}
#[test]
fn csrf_is_bound_to_session() {
let mut h = HeaderMap::new();
h.insert(
"x-csrf-token",
HeaderValue::from_static(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
),
);
assert!(csrf_valid(&h, &digest(&"a".repeat(64))));
assert!(!csrf_valid(&h, &digest(&"b".repeat(64))));
}
#[tokio::test]
async fn throttling_is_atomic_and_persistent() {
let db = SqlitePool::connect("sqlite::memory:").await.unwrap();
init(&db).await.unwrap();
for _ in 0..10 {
assert!(throttle(&db, "test".into()).await.unwrap());
}
assert!(!throttle(&db, "test".into()).await.unwrap());
}
#[test]
fn verifies_laravel_bcrypt() {
let hash = bcrypt::hash("correct", 4)
.unwrap()
.replacen("$2b$", "$2y$", 1);
assert!(verify("correct".into(), hash.clone()));
assert!(!verify("incorrect".into(), hash));
}
#[test]
fn verifies_argon2() {
use argon2::{
PasswordHasher,
password_hash::{SaltString, rand_core::OsRng},
};
let salt = SaltString::generate(&mut OsRng);
let hash = argon2::Argon2::default()
.hash_password(b"correct", &salt)
.unwrap()
.to_string();
assert!(verify("correct".into(), hash.clone()));
assert!(!verify("incorrect".into(), hash));
}
async fn setup() -> (AppState, Router) {
let db = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
crate::db::init(&db).await.unwrap();
init(&db).await.unwrap();
let hash = bcrypt::hash("correct", 4).unwrap();
sqlx::query(
"INSERT INTO users(id,name,email,password) VALUES(1,'Alice','alice@example.com',?)",
)
.bind(hash)
.execute(&db)
.await
.unwrap();
let state = AppState {
db,
cipher: None,
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
};
let app = crate::router(state.clone());
(state, app)
}
#[tokio::test]
async fn legacy_import_rotates_anonymous_session_instead_of_promoting_it() {
let (mut state, app) = setup().await;
let (anonymous, _) = bootstrap(app).await;
state.cipher = Some(
crate::crypto::LaravelCipher::from_app_key("0123456789abcdef0123456789abcdef")
.unwrap()
.into(),
);
sqlx::query("UPDATE users SET remember_token='remember-fixture' WHERE id=1")
.execute(&state.db)
.await
.unwrap();
let name = crate::laravel_sessions::recaller_name();
let recaller = state
.cipher
.as_ref()
.unwrap()
.encrypt_cookie(&name, "1|remember-fixture|unused")
.unwrap();
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{anonymous}; {name}={recaller}")).unwrap(),
);
let response = refresh_csrf(State(state.clone()), headers.clone()).await;
assert_eq!(response.status(), StatusCode::OK);
let imported = cookie(&response);
assert_ne!(imported, anonymous);
assert!(session(&state.db, &headers).await.unwrap().is_none());
headers.insert(header::COOKIE, HeaderValue::from_str(&imported).unwrap());
assert_eq!(
session(&state.db, &headers)
.await
.unwrap()
.unwrap()
.get::<Option<i64>, _>("user_id"),
Some(1)
);
}
#[tokio::test]
async fn invalidation_revokes_native_and_stale_recaller_users() {
let (mut state, _) = setup().await;
state.cipher = Some(
crate::crypto::LaravelCipher::from_app_key("0123456789abcdef0123456789abcdef")
.unwrap()
.into(),
);
sqlx::query("UPDATE users SET remember_token='native-remember' WHERE id=1")
.execute(&state.db)
.await
.unwrap();
sqlx::query("INSERT INTO users(id,name,email,password,remember_token) VALUES(2,'Bob','bob@example.com','unused','legacy-remember')").execute(&state.db).await.unwrap();
let sid = random();
sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,1,?,?)",
)
.bind(digest(&sid))
.bind(digest("csrf"))
.bind(now() + TTL)
.execute(&state.db)
.await
.unwrap();
let name = crate::laravel_sessions::recaller_name();
let recaller = state
.cipher
.as_ref()
.unwrap()
.encrypt_cookie(&name, "2|legacy-remember|unused")
.unwrap();
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{COOKIE}={sid}; {name}={recaller}")).unwrap(),
);
invalidate_browser(&state, &headers).await.unwrap();
assert!(session(&state.db, &headers).await.unwrap().is_none());
assert!(
crate::laravel_sessions::authenticate(&state, &headers)
.await
.unwrap()
.is_none()
);
let tokens: Vec<String> =
sqlx::query_scalar("SELECT remember_token FROM users ORDER BY id")
.fetch_all(&state.db)
.await
.unwrap();
assert_ne!(tokens[0], "native-remember");
assert_ne!(tokens[1], "legacy-remember");
}
async fn request(
app: Router,
method: &str,
path: &str,
cookie: Option<&str>,
csrf: Option<&str>,
body: serde_json::Value,
) -> Response {
use tower::ServiceExt;
let mut builder = axum::http::Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
if let Some(csrf) = csrf {
builder = builder.header("x-csrf-token", csrf);
}
app.oneshot(
builder
.body(axum::body::Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap()
}
fn cookie(response: &Response) -> String {
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find(|v| v.starts_with(COOKIE))
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn bootstrap(app: Router) -> (String, String) {
let response = request(app, "GET", "/refresh-csrf", None, None, json!(null)).await;
assert_eq!(response.status(), StatusCode::OK);
let c = cookie(&response);
assert!(
response
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.contains("HttpOnly")
);
let csrf = String::from_utf8(to_bytes(response.into_body(), 1024).await.unwrap().to_vec())
.unwrap();
(c, csrf)
}
async fn login_as(app: Router, email: &str, password: &str) -> Response {
let (c, csrf) = bootstrap(app.clone()).await;
request(
app,
"POST",
"/user/login",
Some(&c),
Some(&csrf),
json!({"email":email,"password":password}),
)
.await
}
#[tokio::test]
async fn public_login_failure_success_and_logout_write_audit_history() {
use tower::ServiceExt;
let _test_guard = LOGIN_TEST_LOCK.lock().await;
let (state, app) = setup().await;
let (anonymous, csrf) = bootstrap(app.clone()).await;
let send =
|method: &str, path: &str, cookie: &str, csrf: &str, payload: serde_json::Value| {
let request = axum::http::Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.header("cookie", cookie)
.header("x-csrf-token", csrf)
.header("user-agent", "Public audit fixture/1.0")
.header("x-forwarded-for", "198.51.100.123")
.extension(ConnectInfo(
"127.0.0.1:43210".parse::<SocketAddr>().unwrap(),
))
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
app.clone().oneshot(request)
};
let failed = send(
"POST",
"/user/login",
&anonymous,
&csrf,
json!({"email":"alice@example.com","password":"wrong"}),
)
.await
.unwrap();
assert_eq!(failed.status(), StatusCode::UNAUTHORIZED);
let login = send(
"POST",
"/user/login",
&anonymous,
&csrf,
json!({"email":"alice@example.com","password":"correct"}),
)
.await
.unwrap();
assert_eq!(login.status(), StatusCode::OK);
let authenticated = cookie(&login);
let new_csrf = login
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|h| h.to_str().ok())
.find_map(|h| {
h.strip_prefix("XSRF-TOKEN=")
.and_then(|h| h.split(';').next())
})
.unwrap()
.to_owned();
assert_eq!(
send("POST", "/user/logout", &authenticated, &new_csrf, json!({}))
.await
.unwrap()
.status(),
StatusCode::OK
);
let rows = sqlx::query("SELECT * FROM auth_logs WHERE authenticatable_id=1 ORDER BY id")
.fetch_all(&state.db)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert!(!rows[0].get::<bool, _>("login_successful"));
assert!(rows[1].get::<bool, _>("login_successful"));
assert!(rows[1].get::<Option<String>, _>("logout_at").is_some());
for row in rows {
assert_eq!(row.get::<String, _>("ip_address"), "127.0.0.1");
assert_eq!(
row.get::<String, _>("user_agent"),
"Public audit fixture/1.0"
);
assert_eq!(row.get::<String, _>("guard"), "web-guard");
assert_eq!(row.get::<String, _>("login_method"), "password");
}
}
#[tokio::test]
async fn cookie_login_rotation_crud_csrf_logout_and_expiration() {
let _test_guard = LOGIN_TEST_LOCK.lock().await;
let (state, app) = setup().await;
assert_eq!(
request(
app.clone(),
"GET",
"/api/v1/twofaccounts",
None,
None,
json!(null)
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
request(
app.clone(),
"POST",
"/user/login",
None,
None,
json!({"email":"alice@example.com","password":"correct"})
)
.await
.status(),
csrf_status()
);
let (anonymous, csrf) = bootstrap(app.clone()).await;
let response = request(
app.clone(),
"POST",
"/user/login",
Some(&anonymous),
Some(&csrf),
json!({"email":"ALICE@example.com","password":"correct"}),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let session = cookie(&response);
let new_csrf = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|h| h.to_str().ok())
.find_map(|h| {
h.strip_prefix("XSRF-TOKEN=")
.and_then(|h| h.split(';').next())
})
.unwrap()
.to_owned();
assert_ne!(session, anonymous);
let payload: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 64 * 1024).await.unwrap())
.unwrap();
assert!(payload.get("csrf_token").is_none());
assert_ne!(new_csrf, csrf);
assert_eq!(
request(
app.clone(),
"GET",
"/api/v1/twofaccounts",
Some(&anonymous),
None,
json!(null)
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
request(
app.clone(),
"GET",
"/api/v1/twofaccounts",
Some(&session),
None,
json!(null)
)
.await
.status(),
StatusCode::OK
);
let account = json!({"otp_type":"totp","account":"alice","secret":"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ","digits":6,"algorithm":"sha1","period":30});
assert_eq!(
request(
app.clone(),
"POST",
"/api/v1/twofaccounts",
Some(&session),
None,
account.clone()
)
.await
.status(),
csrf_status()
);
assert_eq!(
request(
app.clone(),
"POST",
"/api/v1/twofaccounts",
Some(&session),
Some(&csrf),
account.clone()
)
.await
.status(),
csrf_status()
);
assert_eq!(
request(
app.clone(),
"POST",
"/api/v1/twofaccounts",
Some(&session),
Some(&new_csrf),
account
)
.await
.status(),
StatusCode::CREATED
);
let plain = session.split_once('=').unwrap().1;
let stored: String =
sqlx::query_scalar("SELECT token_hash FROM rust_sessions WHERE user_id=1")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(stored, digest(plain));
assert_ne!(stored, plain);
assert_eq!(
request(
app.clone(),
"POST",
"/user/logout",
Some(&session),
None,
json!(null)
)
.await
.status(),
csrf_status()
);
assert_eq!(
request(
app.clone(),
"GET",
"/user/logout",
Some(&session),
None,
json!(null)
)
.await
.status(),
csrf_status()
);
assert_eq!(
request(
app.clone(),
"GET",
"/user/logout",
Some(&session),
Some(&new_csrf),
json!(null)
)
.await
.status(),
StatusCode::OK
);
assert_eq!(
request(
app.clone(),
"GET",
"/api/v1/twofaccounts",
Some(&session),
None,
json!(null)
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
let response = login_as(app.clone(), "alice@example.com", "correct").await;
assert_eq!(response.status(), StatusCode::OK);
let expired = cookie(&response);
sqlx::query("UPDATE rust_sessions SET expires_at=0")
.execute(&state.db)
.await
.unwrap();
assert_eq!(
request(
app,
"GET",
"/api/v1/twofaccounts",
Some(&expired),
None,
json!(null)
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn respects_existing_sso_and_webauthn_only_accounts() {
let _test_guard = LOGIN_TEST_LOCK.lock().await;
let (state, app) = setup().await;
sqlx::query("INSERT INTO options(key,value) VALUES('useSsoOnly','{{1}}')")
.execute(&state.db)
.await
.unwrap();
assert_eq!(
login_as(app.clone(), "alice@example.com", "correct")
.await
.status(),
StatusCode::FORBIDDEN
);
sqlx::query("UPDATE users SET is_admin=1")
.execute(&state.db)
.await
.unwrap();
assert_eq!(
login_as(app.clone(), "alice@example.com", "correct")
.await
.status(),
StatusCode::OK
);
sqlx::query("UPDATE users SET preferences='{\"useWebauthnOnly\":true}'")
.execute(&state.db)
.await
.unwrap();
assert_eq!(
login_as(app, "alice@example.com", "correct").await.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn password_login_is_throttled_without_proxy_trust() {
let _test_guard = LOGIN_TEST_LOCK.lock().await;
let (_state, app) = setup().await;
let (c, csrf) = bootstrap(app.clone()).await;
for _ in 0..10 {
assert_eq!(
request(
app.clone(),
"POST",
"/user/login",
Some(&c),
Some(&csrf),
json!({"email":"alice@example.com","password":"wrong"})
)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
assert_eq!(
request(
app,
"POST",
"/user/login",
Some(&c),
Some(&csrf),
json!({"email":"alice@example.com","password":"correct"})
)
.await
.status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[tokio::test]
async fn accepts_frontend_xsrf_header_and_urlencoded_credentials() {
let _test_guard = LOGIN_TEST_LOCK.lock().await;
use tower::ServiceExt;
let (state, app) = setup().await;
let (c, csrf) = bootstrap(app.clone()).await;
let req = axum::http::Request::builder()
.method("POST")
.uri("/user/login")
.header("cookie", c)
.header("x-xsrf-token", csrf)
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(
"email=alice%40example.com&password=correct",
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let seen: Option<String> = sqlx::query_scalar("SELECT last_seen_at FROM users WHERE id=1")
.fetch_one(&state.db)
.await
.unwrap();
assert!(seen.is_some());
}
#[tokio::test]
async fn spoofing_forwarded_headers_does_not_reset_peer_throttle() {
let _test_guard = LOGIN_TEST_LOCK.lock().await;
use tower::ServiceExt;
let (_state, app) = setup().await;
let (c, csrf) = bootstrap(app.clone()).await;
for i in 1..=11 {
let req = axum::http::Request::builder()
.method("POST")
.uri("/user/login")
.header("cookie", &c)
.header("x-csrf-token", &csrf)
.header("x-forwarded-for", format!("203.0.113.{i}"))
.header("forwarded", format!("for=203.0.113.{i}"))
.header("content-type", "application/json")
.body(axum::body::Body::from(
json!({"email":"alice@example.com","password":"incorrect"}).to_string(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(
response.status(),
if i <= 10 {
StatusCode::UNAUTHORIZED
} else {
StatusCode::TOO_MANY_REQUESTS
}
);
}
}
}