1575 lines
55 KiB
Rust
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
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|