Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "nomifun-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
jsonwebtoken = "9" # pinned: be-rs auth was written against v9 (workspace default is v10 for nomi-providers)
|
||||
bcrypt.workspace = true
|
||||
tower.workspace = true
|
||||
dashmap.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
getrandom.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
http-body-util.workspace = true
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Per-companion access tokens for the Remote capability front door (`/mcp`).
|
||||
//!
|
||||
//! Each external connection binds to exactly one companion. Tokens are minted
|
||||
//! with [`crate::generate_random_hex_secret`], persisted only as a SHA-256 hash,
|
||||
//! and revocable. This module holds the hashing primitive and an in-memory
|
||||
//! validator mapping `token → companion_id`, hot-swapped on mint/revoke.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// SHA-256 of `token`, lowercase hex (64 chars).
|
||||
pub fn token_sha256_hex(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Constant-time string compare (both inputs are fixed-length hex hashes here).
|
||||
fn ct_eq(a: &str, b: &str) -> bool {
|
||||
let a = a.as_bytes();
|
||||
let b = b.as_bytes();
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Resolves a presented Remote token to the companion it is bound to.
|
||||
///
|
||||
/// Holds `companion_id → token_hash` in memory, hot-swapped via
|
||||
/// [`insert_token`](Self::insert_token) / [`remove_token`](Self::remove_token)
|
||||
/// on mint/revoke, so no DB round-trip is needed per request. An empty map means
|
||||
/// no companion has a token → every `resolve` returns `None` (the front door is
|
||||
/// closed until a token is minted).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CompanionTokenValidator {
|
||||
/// companion_id -> token_hash
|
||||
tokens: RwLock<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl CompanionTokenValidator {
|
||||
/// Build a validator seeded with persisted `(companion_id, token_hash)` pairs.
|
||||
pub fn new(initial: Vec<(String, String)>) -> Self {
|
||||
Self { tokens: RwLock::new(initial.into_iter().collect()) }
|
||||
}
|
||||
|
||||
/// Resolve a presented token to its bound `companion_id`, or `None` if it
|
||||
/// matches no companion. Constant-time per-entry compare.
|
||||
pub fn resolve(&self, presented_token: &str) -> Option<String> {
|
||||
if presented_token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let presented_hash = token_sha256_hex(presented_token);
|
||||
let map = self.tokens.read().expect("companion token lock poisoned");
|
||||
for (companion_id, stored) in map.iter() {
|
||||
if ct_eq(&presented_hash, stored) {
|
||||
return Some(companion_id.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mint/rotate the token for a companion (replaces any prior token).
|
||||
pub fn insert_token(&self, companion_id: String, token_hash: String) {
|
||||
self.tokens.write().expect("companion token lock poisoned").insert(companion_id, token_hash);
|
||||
}
|
||||
|
||||
/// Revoke a companion's token.
|
||||
pub fn remove_token(&self, companion_id: &str) {
|
||||
self.tokens.write().expect("companion token lock poisoned").remove(companion_id);
|
||||
}
|
||||
|
||||
/// Whether a companion currently has a token configured (status endpoint).
|
||||
pub fn is_configured_for(&self, companion_id: &str) -> bool {
|
||||
self.tokens.read().expect("companion token lock poisoned").contains_key(companion_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_minted_token_to_its_companion() {
|
||||
let token_a = crate::generate_random_hex_secret();
|
||||
let token_b = crate::generate_random_hex_secret();
|
||||
let v = CompanionTokenValidator::new(vec![("comp-a".into(), token_sha256_hex(&token_a))]);
|
||||
assert!(v.is_configured_for("comp-a"));
|
||||
assert_eq!(v.resolve(&token_a).as_deref(), Some("comp-a"));
|
||||
assert_eq!(v.resolve(&token_b), None);
|
||||
assert_eq!(v.resolve("wrong"), None);
|
||||
assert_eq!(v.resolve(""), None);
|
||||
|
||||
// Mint for a second companion.
|
||||
v.insert_token("comp-b".into(), token_sha256_hex(&token_b));
|
||||
assert_eq!(v.resolve(&token_b).as_deref(), Some("comp-b"));
|
||||
|
||||
// Revocation closes that companion's door only.
|
||||
v.remove_token("comp-a");
|
||||
assert!(!v.is_configured_for("comp-a"));
|
||||
assert_eq!(v.resolve(&token_a), None);
|
||||
assert_eq!(v.resolve(&token_b).as_deref(), Some("comp-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_replaces_prior_token_for_same_companion() {
|
||||
let old = crate::generate_random_hex_secret();
|
||||
let new = crate::generate_random_hex_secret();
|
||||
let v = CompanionTokenValidator::default();
|
||||
v.insert_token("comp".into(), token_sha256_hex(&old));
|
||||
v.insert_token("comp".into(), token_sha256_hex(&new));
|
||||
assert_eq!(v.resolve(&old), None);
|
||||
assert_eq!(v.resolve(&new).as_deref(), Some("comp"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use nomifun_common::constants::{COOKIE_MAX_AGE_DAYS, COOKIE_NAME, CSRF_COOKIE_NAME};
|
||||
|
||||
/// Cookie security configuration derived from the deployment environment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CookieConfig {
|
||||
/// Whether to set the `Secure` flag on cookies (HTTPS only).
|
||||
pub secure: bool,
|
||||
/// `SameSite` policy: `"Strict"` for HTTPS, `"Lax"` for HTTP.
|
||||
pub same_site: &'static str,
|
||||
}
|
||||
|
||||
impl CookieConfig {
|
||||
/// Create cookie config from environment variables.
|
||||
///
|
||||
/// - `NOMIFUN_HTTPS=true` → Secure flag, SameSite=Strict
|
||||
/// - Otherwise → no Secure flag, SameSite=Lax (for remote HTTP access)
|
||||
pub fn from_env() -> Self {
|
||||
let https = std::env::var("NOMIFUN_HTTPS")
|
||||
.map(|v| v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
secure: https,
|
||||
same_site: if https { "Strict" } else { "Lax" },
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `Set-Cookie` header value for the session token.
|
||||
///
|
||||
/// Attributes: HttpOnly, SameSite, Secure (if HTTPS), Max-Age=30d.
|
||||
pub fn build_session_cookie(&self, token: &str) -> String {
|
||||
let max_age = u64::from(COOKIE_MAX_AGE_DAYS) * 24 * 60 * 60;
|
||||
format!(
|
||||
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite={}{}; Max-Age={max_age}",
|
||||
self.same_site,
|
||||
if self.secure { "; Secure" } else { "" },
|
||||
)
|
||||
}
|
||||
|
||||
/// Build `Set-Cookie` header value that clears the session cookie.
|
||||
pub fn clear_session_cookie(&self) -> String {
|
||||
format!(
|
||||
"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite={}{}; Max-Age=0",
|
||||
self.same_site,
|
||||
if self.secure { "; Secure" } else { "" },
|
||||
)
|
||||
}
|
||||
|
||||
/// Build `Set-Cookie` header value for the CSRF token.
|
||||
///
|
||||
/// NOT HttpOnly — JavaScript must read this value to include it
|
||||
/// in the `x-csrf-token` request header (Double Submit Cookie pattern).
|
||||
pub fn build_csrf_cookie(&self, token: &str) -> String {
|
||||
let max_age = u64::from(COOKIE_MAX_AGE_DAYS) * 24 * 60 * 60;
|
||||
format!(
|
||||
"{CSRF_COOKIE_NAME}={token}; Path=/; SameSite={}{}; Max-Age={max_age}",
|
||||
self.same_site,
|
||||
if self.secure { "; Secure" } else { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn http_config() -> CookieConfig {
|
||||
CookieConfig {
|
||||
secure: false,
|
||||
same_site: "Lax",
|
||||
}
|
||||
}
|
||||
|
||||
fn https_config() -> CookieConfig {
|
||||
CookieConfig {
|
||||
secure: true,
|
||||
same_site: "Strict",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_http() {
|
||||
let cookie = http_config().build_session_cookie("my_token");
|
||||
assert!(cookie.contains("nomifun-session=my_token"));
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
assert!(cookie.contains("Path=/"));
|
||||
assert!(cookie.contains("Max-Age="));
|
||||
assert!(!cookie.contains("Secure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_https() {
|
||||
let cookie = https_config().build_session_cookie("my_token");
|
||||
assert!(cookie.contains("SameSite=Strict"));
|
||||
assert!(cookie.contains("; Secure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_session_cookie_sets_max_age_zero() {
|
||||
let cookie = http_config().clear_session_cookie();
|
||||
assert!(cookie.contains("nomifun-session="));
|
||||
assert!(cookie.contains("Max-Age=0"));
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_cookie_not_http_only() {
|
||||
let cookie = http_config().build_csrf_cookie("csrf_abc");
|
||||
assert!(cookie.contains("nomifun-csrf-token=csrf_abc"));
|
||||
assert!(!cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
assert!(cookie.contains("Max-Age="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_cookie_https_has_secure() {
|
||||
let cookie = https_config().build_csrf_cookie("csrf_abc");
|
||||
assert!(cookie.contains("; Secure"));
|
||||
assert!(cookie.contains("SameSite=Strict"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_max_age_30_days() {
|
||||
let cookie = http_config().build_session_cookie("t");
|
||||
let expected = 30 * 24 * 60 * 60;
|
||||
assert!(cookie.contains(&format!("Max-Age={expected}")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderValue, Method, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_common::constants::{CSRF_COOKIE_NAME, CSRF_HEADER_NAME};
|
||||
|
||||
use crate::cookie::CookieConfig;
|
||||
use crate::extract::extract_cookie_value;
|
||||
|
||||
/// CSRF protection middleware using the Double Submit Cookie pattern.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Safe methods (GET, HEAD, OPTIONS) bypass validation.
|
||||
/// - Exempt paths (`/login`, `/api/auth/qr-login`, `/api/auth/setup`) bypass validation.
|
||||
/// - All other requests must include an `x-csrf-token` header whose value
|
||||
/// matches the `nomifun-csrf-token` cookie.
|
||||
/// - Sets the CSRF cookie on responses if the client does not have one.
|
||||
pub async fn csrf_middleware(
|
||||
State(cookie_config): State<Arc<CookieConfig>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_owned();
|
||||
|
||||
// Extract CSRF cookie before consuming the request
|
||||
let csrf_cookie = extract_cookie_value(request.headers(), CSRF_COOKIE_NAME);
|
||||
|
||||
// Validate CSRF for state-changing requests
|
||||
let needs_validation = matches!(method, Method::POST | Method::PUT | Method::DELETE | Method::PATCH);
|
||||
let is_exempt = path == "/login" || path == "/api/auth/qr-login" || path == "/api/auth/setup";
|
||||
|
||||
// Locally-trusted requests authenticate via the `X-Nomi-Local-Trust` header,
|
||||
// not an ambient cookie, so they are not a CSRF target — skip validation.
|
||||
let local_trusted = request.extensions().get::<crate::trust::LocalTrusted>().is_some();
|
||||
|
||||
if needs_validation && !is_exempt && !local_trusted {
|
||||
let header_token = request
|
||||
.headers()
|
||||
.get(CSRF_HEADER_NAME)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_owned());
|
||||
|
||||
match (&csrf_cookie, header_token) {
|
||||
(Some(cookie), Some(ref hdr)) if !cookie.is_empty() && cookie == hdr => {
|
||||
// Valid: cookie and header match
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Forbidden("CSRF token validation failed".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
// Set CSRF cookie if the client doesn't have one
|
||||
if csrf_cookie.is_none() {
|
||||
let token = generate_csrf_token();
|
||||
let cookie_str = cookie_config.build_csrf_cookie(&token);
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie_str) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random 32-byte CSRF token as a hex string.
|
||||
fn generate_csrf_token() -> String {
|
||||
let mut buf = [0u8; 32];
|
||||
getrandom::getrandom(&mut buf).expect("OS entropy source unavailable");
|
||||
let mut hex = String::with_capacity(64);
|
||||
for byte in buf {
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn csrf_token_is_64_hex_chars() {
|
||||
let token = generate_csrf_token();
|
||||
assert_eq!(token.len(), 64);
|
||||
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_tokens_are_unique() {
|
||||
let t1 = generate_csrf_token();
|
||||
let t2 = generate_csrf_token();
|
||||
assert_ne!(t1, t2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// Authentication-layer errors.
|
||||
///
|
||||
/// Converts to `AppError` for HTTP response mapping.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("Password validation failed: {0}")]
|
||||
WeakPassword(String),
|
||||
|
||||
#[error("Username validation failed: {0}")]
|
||||
InvalidUsername(String),
|
||||
|
||||
#[error("Token expired")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("Token invalid: {0}")]
|
||||
TokenInvalid(String),
|
||||
|
||||
#[error("Token blacklisted")]
|
||||
TokenBlacklisted,
|
||||
|
||||
#[error("Password hash error: {0}")]
|
||||
HashError(String),
|
||||
}
|
||||
|
||||
impl From<AuthError> for AppError {
|
||||
fn from(err: AuthError) -> Self {
|
||||
match err {
|
||||
AuthError::InvalidCredentials => AppError::Unauthorized("Invalid username or password".into()),
|
||||
AuthError::WeakPassword(msg) => AppError::BadRequest(msg),
|
||||
AuthError::InvalidUsername(msg) => AppError::BadRequest(msg),
|
||||
AuthError::TokenExpired => AppError::Unauthorized("Token expired".into()),
|
||||
AuthError::TokenInvalid(msg) => AppError::Unauthorized(msg),
|
||||
AuthError::TokenBlacklisted => AppError::Unauthorized("Token has been revoked".into()),
|
||||
AuthError::HashError(msg) => AppError::Internal(format!("Password hash error: {msg}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn invalid_credentials_maps_to_unauthorized() {
|
||||
let app_err: AppError = AuthError::InvalidCredentials.into();
|
||||
assert_eq!(app_err.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_password_maps_to_bad_request() {
|
||||
let app_err: AppError = AuthError::WeakPassword("too short".into()).into();
|
||||
assert_eq!(app_err.status_code(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_username_maps_to_bad_request() {
|
||||
let app_err: AppError = AuthError::InvalidUsername("bad chars".into()).into();
|
||||
assert_eq!(app_err.status_code(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_expired_maps_to_unauthorized() {
|
||||
let app_err: AppError = AuthError::TokenExpired.into();
|
||||
assert_eq!(app_err.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_invalid_maps_to_unauthorized() {
|
||||
let app_err: AppError = AuthError::TokenInvalid("bad".into()).into();
|
||||
assert_eq!(app_err.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_blacklisted_maps_to_unauthorized() {
|
||||
let app_err: AppError = AuthError::TokenBlacklisted.into();
|
||||
assert_eq!(app_err.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_error_maps_to_internal() {
|
||||
let app_err: AppError = AuthError::HashError("failed".into()).into();
|
||||
assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use axum::http::{HeaderMap, Request, header};
|
||||
|
||||
use nomifun_common::constants::COOKIE_NAME;
|
||||
|
||||
/// Extract the client IP address from request headers.
|
||||
///
|
||||
/// Priority: `X-Forwarded-For` (first IP) > `X-Real-IP` > `"unknown"`.
|
||||
pub fn extract_client_ip<B>(request: &Request<B>) -> String {
|
||||
extract_client_ip_from_headers(request.headers())
|
||||
}
|
||||
|
||||
/// Extract client IP from a `HeaderMap` directly.
|
||||
pub fn extract_client_ip_from_headers(headers: &HeaderMap) -> String {
|
||||
// X-Forwarded-For: client, proxy1, proxy2
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(first_ip) = forwarded.split(',').next()
|
||||
{
|
||||
let ip = first_ip.trim();
|
||||
if !ip.is_empty() {
|
||||
return ip.to_owned();
|
||||
}
|
||||
}
|
||||
|
||||
// X-Real-IP
|
||||
if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
||||
let ip = real_ip.trim();
|
||||
if !ip.is_empty() {
|
||||
return ip.to_owned();
|
||||
}
|
||||
}
|
||||
|
||||
"unknown".to_owned()
|
||||
}
|
||||
|
||||
/// Extract bearer token from HTTP request headers.
|
||||
///
|
||||
/// Priority: `Authorization: Bearer <token>` > `nomifun-session` cookie.
|
||||
pub fn extract_token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(token) = extract_bearer_token(headers) {
|
||||
return Some(token);
|
||||
}
|
||||
extract_cookie_value(headers, COOKIE_NAME)
|
||||
}
|
||||
|
||||
/// Extract bearer token from WebSocket upgrade request headers.
|
||||
///
|
||||
/// Priority: `Authorization` > `Cookie` > `Sec-WebSocket-Protocol` (first value).
|
||||
pub fn extract_token_from_ws_headers(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(token) = extract_bearer_token(headers) {
|
||||
return Some(token);
|
||||
}
|
||||
|
||||
if let Some(token) = extract_cookie_value(headers, COOKIE_NAME) {
|
||||
return Some(token);
|
||||
}
|
||||
|
||||
// Sec-WebSocket-Protocol: <token>, ...
|
||||
headers
|
||||
.get("sec-websocket-protocol")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|protocols| {
|
||||
let first = protocols.split(',').next()?.trim();
|
||||
if first.is_empty() { None } else { Some(first.to_owned()) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from the `Cookie` header.
|
||||
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||
for part in cookie_header.split(';') {
|
||||
let Some((key, value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if key.trim() == name {
|
||||
let v = value.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the bearer token from the `Authorization` header.
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||
let token = auth.strip_prefix("Bearer ")?;
|
||||
if token.is_empty() { None } else { Some(token.to_owned()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
fn headers_with(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||
let mut map = HeaderMap::new();
|
||||
for &(name, value) in pairs {
|
||||
map.insert(
|
||||
axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(value).unwrap(),
|
||||
);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// --- extract_client_ip ---
|
||||
|
||||
#[test]
|
||||
fn ip_from_x_forwarded_for() {
|
||||
let headers = headers_with(&[("x-forwarded-for", "1.2.3.4, 5.6.7.8")]);
|
||||
assert_eq!(extract_client_ip_from_headers(&headers), "1.2.3.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_from_x_real_ip() {
|
||||
let headers = headers_with(&[("x-real-ip", "10.0.0.1")]);
|
||||
assert_eq!(extract_client_ip_from_headers(&headers), "10.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_forwarded_for_takes_priority() {
|
||||
let headers = headers_with(&[("x-forwarded-for", "1.2.3.4"), ("x-real-ip", "10.0.0.1")]);
|
||||
assert_eq!(extract_client_ip_from_headers(&headers), "1.2.3.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_fallback_to_unknown() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_client_ip_from_headers(&headers), "unknown");
|
||||
}
|
||||
|
||||
// --- extract_token_from_headers ---
|
||||
|
||||
#[test]
|
||||
fn token_from_authorization_header() {
|
||||
let headers = headers_with(&[("authorization", "Bearer my_jwt_token")]);
|
||||
assert_eq!(extract_token_from_headers(&headers), Some("my_jwt_token".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_from_cookie() {
|
||||
let headers = headers_with(&[("cookie", "nomifun-session=cookie_token; other=val")]);
|
||||
assert_eq!(extract_token_from_headers(&headers), Some("cookie_token".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_header_takes_priority_over_cookie() {
|
||||
let headers = headers_with(&[
|
||||
("authorization", "Bearer header_token"),
|
||||
("cookie", "nomifun-session=cookie_token"),
|
||||
]);
|
||||
assert_eq!(extract_token_from_headers(&headers), Some("header_token".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_none_when_missing() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_token_from_headers(&headers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_none_for_empty_bearer() {
|
||||
let headers = headers_with(&[("authorization", "Bearer ")]);
|
||||
assert_eq!(extract_token_from_headers(&headers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_none_for_non_bearer_auth() {
|
||||
let headers = headers_with(&[("authorization", "Basic dXNlcjpwYXNz")]);
|
||||
assert_eq!(extract_token_from_headers(&headers), None);
|
||||
}
|
||||
|
||||
// --- extract_token_from_ws_headers ---
|
||||
|
||||
#[test]
|
||||
fn ws_token_from_authorization() {
|
||||
let headers = headers_with(&[("authorization", "Bearer ws_token")]);
|
||||
assert_eq!(extract_token_from_ws_headers(&headers), Some("ws_token".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_token_from_cookie() {
|
||||
let headers = headers_with(&[("cookie", "nomifun-session=ws_cookie")]);
|
||||
assert_eq!(extract_token_from_ws_headers(&headers), Some("ws_cookie".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_token_from_sec_websocket_protocol() {
|
||||
let headers = headers_with(&[("sec-websocket-protocol", "my_ws_token, graphql-ws")]);
|
||||
assert_eq!(extract_token_from_ws_headers(&headers), Some("my_ws_token".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_token_priority_order() {
|
||||
let headers = headers_with(&[
|
||||
("authorization", "Bearer auth_token"),
|
||||
("cookie", "nomifun-session=cookie_token"),
|
||||
("sec-websocket-protocol", "proto_token"),
|
||||
]);
|
||||
assert_eq!(extract_token_from_ws_headers(&headers), Some("auth_token".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_token_fallback_through_sources() {
|
||||
// Only cookie and protocol, no authorization
|
||||
let headers = headers_with(&[
|
||||
("cookie", "nomifun-session=cookie_token"),
|
||||
("sec-websocket-protocol", "proto_token"),
|
||||
]);
|
||||
assert_eq!(extract_token_from_ws_headers(&headers), Some("cookie_token".into()));
|
||||
}
|
||||
|
||||
// --- extract_cookie_value ---
|
||||
|
||||
#[test]
|
||||
fn cookie_value_extracted() {
|
||||
let headers = headers_with(&[("cookie", "a=1; target=hello; b=2")]);
|
||||
assert_eq!(extract_cookie_value(&headers, "target"), Some("hello".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_value_not_found() {
|
||||
let headers = headers_with(&[("cookie", "a=1; b=2")]);
|
||||
assert_eq!(extract_cookie_value(&headers, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_value_no_cookie_header() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_cookie_value(&headers, "any"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_value_skips_malformed_entries() {
|
||||
// Entry without '=' should be skipped, not abort the entire search
|
||||
let headers = headers_with(&[("cookie", "malformed; target=found; also_bad")]);
|
||||
assert_eq!(extract_cookie_value(&headers, "target"), Some("found".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_value_all_malformed_returns_none() {
|
||||
let headers = headers_with(&[("cookie", "no_equals; also_none")]);
|
||||
assert_eq!(extract_cookie_value(&headers, "target"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_value_malformed_before_target() {
|
||||
// Malformed entry appears before the target cookie
|
||||
let headers = headers_with(&[("cookie", "bad_entry; nomifun-session=tok123")]);
|
||||
assert_eq!(extract_cookie_value(&headers, "nomifun-session"), Some("tok123".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_from_cookie_with_malformed_entries() {
|
||||
// End-to-end: extract_token_from_headers should still find the
|
||||
// session cookie even when other entries lack '='
|
||||
let headers = headers_with(&[("cookie", "garbage; nomifun-session=abc; nope")]);
|
||||
assert_eq!(extract_token_from_headers(&headers), Some("abc".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::Engine as _;
|
||||
use dashmap::DashMap;
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::AuthError;
|
||||
|
||||
/// JWT token lifetime: 24 hours.
|
||||
const TOKEN_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// JWT issuer claim value.
|
||||
const JWT_ISSUER: &str = "nomifun";
|
||||
|
||||
/// JWT audience claim value.
|
||||
const JWT_AUDIENCE: &str = "nomifun-webui";
|
||||
|
||||
/// JWT payload (claims embedded in the token).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenPayload {
|
||||
/// User ID.
|
||||
pub user_id: String,
|
||||
/// Username.
|
||||
pub username: String,
|
||||
/// User role: 'admin' or 'user'
|
||||
#[serde(default = "default_role")]
|
||||
pub role: String,
|
||||
/// Issued-at timestamp (seconds since UNIX epoch).
|
||||
pub iat: u64,
|
||||
/// Expiration timestamp (seconds since UNIX epoch).
|
||||
pub exp: u64,
|
||||
/// Issuer (standard JWT claim).
|
||||
pub iss: String,
|
||||
/// Audience (standard JWT claim).
|
||||
pub aud: String,
|
||||
}
|
||||
|
||||
fn default_role() -> String {
|
||||
"user".to_string()
|
||||
}
|
||||
|
||||
/// JWT service for signing, verification, and token blacklisting.
|
||||
///
|
||||
/// Thread-safe: the secret is behind a `RwLock` and the blacklist uses `DashMap`.
|
||||
pub struct JwtService {
|
||||
/// Current signing/verification secret (rotatable).
|
||||
secret: RwLock<String>,
|
||||
/// Blacklisted token hashes -> expiry timestamps.
|
||||
blacklist: DashMap<String, u64>,
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
/// Create a new JWT service with the given secret string.
|
||||
///
|
||||
/// The secret's bytes are used as the HMAC-SHA256 key.
|
||||
pub fn new(secret: String) -> Self {
|
||||
Self {
|
||||
secret: RwLock::new(secret),
|
||||
blacklist: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a new JWT for the given user. The token expires after 24 hours.
|
||||
pub fn sign(&self, user_id: &str, username: &str, role: &str) -> Result<String, AuthError> {
|
||||
let now = now_secs()?;
|
||||
let exp = now + TOKEN_EXPIRY.as_secs();
|
||||
|
||||
let claims = TokenPayload {
|
||||
user_id: user_id.to_owned(),
|
||||
username: username.to_owned(),
|
||||
role: role.to_owned(),
|
||||
iat: now,
|
||||
exp,
|
||||
iss: JWT_ISSUER.to_owned(),
|
||||
aud: JWT_AUDIENCE.to_owned(),
|
||||
};
|
||||
|
||||
let secret = self
|
||||
.secret
|
||||
.read()
|
||||
.map_err(|e| AuthError::TokenInvalid(format!("Secret lock poisoned: {e}")))?;
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| AuthError::TokenInvalid(format!("JWT encoding failed: {e}")))
|
||||
}
|
||||
|
||||
/// Verify a JWT and return its payload.
|
||||
///
|
||||
/// Checks: blacklist, signature, expiration, issuer, audience.
|
||||
pub fn verify(&self, token: &str) -> Result<TokenPayload, AuthError> {
|
||||
let hash = token_hash(token);
|
||||
if self.blacklist.contains_key(&hash) {
|
||||
return Err(AuthError::TokenBlacklisted);
|
||||
}
|
||||
|
||||
let secret = self
|
||||
.secret
|
||||
.read()
|
||||
.map_err(|e| AuthError::TokenInvalid(format!("Secret lock poisoned: {e}")))?;
|
||||
|
||||
let mut validation = Validation::default();
|
||||
validation.set_issuer(&[JWT_ISSUER]);
|
||||
validation.set_audience(&[JWT_AUDIENCE]);
|
||||
|
||||
let token_data = decode::<TokenPayload>(token, &DecodingKey::from_secret(secret.as_bytes()), &validation)
|
||||
.map_err(|e| match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
|
||||
_ => AuthError::TokenInvalid(format!("JWT verification failed: {e}")),
|
||||
})?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
/// Add a token to the blacklist.
|
||||
///
|
||||
/// Stores the token's SHA-256 hash with its expiry time for automatic cleanup.
|
||||
pub fn blacklist_token(&self, token: &str) {
|
||||
let hash = token_hash(token);
|
||||
let exp = self
|
||||
.extract_expiry(token)
|
||||
.unwrap_or_else(|| now_secs().unwrap_or(0) + TOKEN_EXPIRY.as_secs());
|
||||
self.blacklist.insert(hash, exp);
|
||||
}
|
||||
|
||||
/// Rotate the JWT secret, invalidating all previously issued tokens.
|
||||
///
|
||||
/// Returns the new secret string for database persistence.
|
||||
pub fn rotate_secret(&self) -> Result<String, AuthError> {
|
||||
let new_secret = generate_random_secret_string();
|
||||
let mut secret = self
|
||||
.secret
|
||||
.write()
|
||||
.map_err(|e| AuthError::TokenInvalid(format!("Secret lock poisoned: {e}")))?;
|
||||
*secret = new_secret.clone();
|
||||
// All old tokens are invalid with the new secret; clear the blacklist
|
||||
self.blacklist.clear();
|
||||
tracing::info!("JWT secret rotated; all existing tokens invalidated");
|
||||
Ok(new_secret)
|
||||
}
|
||||
|
||||
/// Remove expired entries from the blacklist.
|
||||
pub fn cleanup_blacklist(&self) {
|
||||
let now = now_secs().unwrap_or(0);
|
||||
self.blacklist.retain(|_, exp| *exp > now);
|
||||
}
|
||||
|
||||
/// Number of entries in the blacklist (for monitoring/testing).
|
||||
pub fn blacklist_size(&self) -> usize {
|
||||
self.blacklist.len()
|
||||
}
|
||||
|
||||
/// Try to extract the expiry time from a token without rejecting expired tokens.
|
||||
fn extract_expiry(&self, token: &str) -> Option<u64> {
|
||||
let secret = self.secret.read().ok()?;
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = false;
|
||||
validation.set_issuer(&[JWT_ISSUER]);
|
||||
validation.set_audience(&[JWT_AUDIENCE]);
|
||||
|
||||
decode::<TokenPayload>(token, &DecodingKey::from_secret(secret.as_bytes()), &validation)
|
||||
.ok()
|
||||
.map(|data| data.claims.exp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the JWT secret from available sources.
|
||||
///
|
||||
/// Priority: environment variable -> database value -> random generation.
|
||||
/// Returns `(secret_string, is_newly_generated)`.
|
||||
pub fn resolve_jwt_secret(env_secret: Option<&str>, db_secret: Option<&str>) -> (String, bool) {
|
||||
if let Some(s) = env_secret {
|
||||
return (s.to_owned(), false);
|
||||
}
|
||||
if let Some(s) = db_secret {
|
||||
return (s.to_owned(), false);
|
||||
}
|
||||
(generate_random_secret_string(), true)
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random 64-byte secret, base64-encoded.
|
||||
pub fn generate_random_secret_string() -> String {
|
||||
let mut buf = [0u8; 64];
|
||||
// getrandom failure is fatal — mirrors nomifun-common's UUID generation.
|
||||
getrandom::getrandom(&mut buf).expect("OS entropy source unavailable");
|
||||
base64::engine::general_purpose::STANDARD.encode(buf)
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random 256-bit secret as a lowercase hex string
|
||||
/// (64 chars, `[0-9a-f]`).
|
||||
///
|
||||
/// Unlike [`generate_random_secret_string`] (STANDARD base64, which contains
|
||||
/// `+`/`/`/`=`), every character here is a valid RFC 7230 token char. That makes
|
||||
/// the value safe to carry as a `Sec-WebSocket-Protocol` subprotocol — the
|
||||
/// desktop's local-trust secret rides the WS handshake that way (browsers cannot
|
||||
/// set custom headers on a WS upgrade), and `new WebSocket(url, [secret])` throws
|
||||
/// a SyntaxError if the subprotocol token is malformed. A base64 secret silently
|
||||
/// broke EVERY desktop WebSocket connection (no live `message.stream` → the
|
||||
/// desktop companion bubble never echoed replies). Also fine in the
|
||||
/// `x-nomi-local-trust` HTTP header.
|
||||
pub fn generate_random_hex_secret() -> String {
|
||||
let mut buf = [0u8; 32];
|
||||
getrandom::getrandom(&mut buf).expect("OS entropy source unavailable");
|
||||
buf.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Current time in seconds since UNIX epoch.
|
||||
fn now_secs() -> Result<u64, AuthError> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.map_err(|e| AuthError::TokenInvalid(format!("System clock error: {e}")))
|
||||
}
|
||||
|
||||
/// Compute the SHA-256 hash of a token string, returned as hex.
|
||||
fn token_hash(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let mut hex = String::with_capacity(64);
|
||||
for byte in result {
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_service() -> JwtService {
|
||||
JwtService::new("test_secret_key_for_testing".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_produces_valid_jwt_format() {
|
||||
let service = test_service();
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
assert!(!token.is_empty());
|
||||
assert_eq!(token.split('.').count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify_roundtrip() {
|
||||
let service = test_service();
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
let payload = service.verify(&token).unwrap();
|
||||
assert_eq!(payload.user_id, "user_1");
|
||||
assert_eq!(payload.username, "admin");
|
||||
assert_eq!(payload.role, "admin");
|
||||
assert_eq!(payload.iss, JWT_ISSUER);
|
||||
assert_eq!(payload.aud, JWT_AUDIENCE);
|
||||
assert!(payload.exp > payload.iat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_tampered_token_fails() {
|
||||
let service = test_service();
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
let tampered = format!("{token}x");
|
||||
assert!(matches!(service.verify(&tampered), Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_wrong_secret_fails() {
|
||||
let service1 = JwtService::new("secret_1".into());
|
||||
let service2 = JwtService::new("secret_2".into());
|
||||
let token = service1.sign("user_1", "admin", "admin").unwrap();
|
||||
assert!(service2.verify(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_expired_token() {
|
||||
let service = test_service();
|
||||
let secret = service.secret.read().unwrap();
|
||||
|
||||
let claims = TokenPayload {
|
||||
user_id: "user_1".into(),
|
||||
username: "admin".into(),
|
||||
role: "admin".into(),
|
||||
iat: 1000,
|
||||
exp: 1001,
|
||||
iss: JWT_ISSUER.into(),
|
||||
aud: JWT_AUDIENCE.into(),
|
||||
};
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
drop(secret);
|
||||
|
||||
assert!(matches!(service.verify(&token), Err(AuthError::TokenExpired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blacklist_token_then_verify_fails() {
|
||||
let service = test_service();
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
assert!(service.verify(&token).is_ok());
|
||||
|
||||
service.blacklist_token(&token);
|
||||
assert!(matches!(service.verify(&token), Err(AuthError::TokenBlacklisted)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blacklist_size_tracking() {
|
||||
let service = test_service();
|
||||
assert_eq!(service.blacklist_size(), 0);
|
||||
|
||||
let token1 = service.sign("user_1", "admin", "admin").unwrap();
|
||||
let token2 = service.sign("user_2", "user", "user").unwrap();
|
||||
|
||||
service.blacklist_token(&token1);
|
||||
assert_eq!(service.blacklist_size(), 1);
|
||||
|
||||
service.blacklist_token(&token2);
|
||||
assert_eq!(service.blacklist_size(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_secret_invalidates_old_tokens() {
|
||||
let service = test_service();
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
assert!(service.verify(&token).is_ok());
|
||||
|
||||
service.rotate_secret().unwrap();
|
||||
assert!(service.verify(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_secret_clears_blacklist() {
|
||||
let service = test_service();
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
service.blacklist_token(&token);
|
||||
assert_eq!(service.blacklist_size(), 1);
|
||||
|
||||
service.rotate_secret().unwrap();
|
||||
assert_eq!(service.blacklist_size(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_secret_allows_new_tokens() {
|
||||
let service = test_service();
|
||||
service.rotate_secret().unwrap();
|
||||
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
let payload = service.verify(&token).unwrap();
|
||||
assert_eq!(payload.user_id, "user_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_expired_entries() {
|
||||
let service = test_service();
|
||||
let secret = service.secret.read().unwrap();
|
||||
|
||||
// Create a token with an already-past expiry
|
||||
let claims = TokenPayload {
|
||||
user_id: "user_1".into(),
|
||||
username: "admin".into(),
|
||||
role: "admin".into(),
|
||||
iat: 1000,
|
||||
exp: 1001,
|
||||
iss: JWT_ISSUER.into(),
|
||||
aud: JWT_AUDIENCE.into(),
|
||||
};
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
drop(secret);
|
||||
|
||||
service.blacklist_token(&token);
|
||||
assert_eq!(service.blacklist_size(), 1);
|
||||
|
||||
service.cleanup_blacklist();
|
||||
assert_eq!(service.blacklist_size(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_valid_entries() {
|
||||
let service = test_service();
|
||||
let token = service.sign("user_1", "admin", "admin").unwrap();
|
||||
service.blacklist_token(&token);
|
||||
assert_eq!(service.blacklist_size(), 1);
|
||||
|
||||
service.cleanup_blacklist();
|
||||
// Token just signed with 24h expiry should still be in blacklist
|
||||
assert_eq!(service.blacklist_size(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_jwt_secret_env_priority() {
|
||||
let (secret, generated) = resolve_jwt_secret(Some("env_secret"), Some("db_secret"));
|
||||
assert_eq!(secret, "env_secret");
|
||||
assert!(!generated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_jwt_secret_db_fallback() {
|
||||
let (secret, generated) = resolve_jwt_secret(None, Some("db_secret"));
|
||||
assert_eq!(secret, "db_secret");
|
||||
assert!(!generated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_jwt_secret_generates_new() {
|
||||
let (secret, generated) = resolve_jwt_secret(None, None);
|
||||
assert!(!secret.is_empty());
|
||||
assert!(generated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_secret_is_unique() {
|
||||
let s1 = generate_random_secret_string();
|
||||
let s2 = generate_random_secret_string();
|
||||
assert_ne!(s1, s2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_hash_is_deterministic() {
|
||||
let h1 = token_hash("test_token");
|
||||
let h2 = token_hash("test_token");
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_hash_differs_for_different_inputs() {
|
||||
let h1 = token_hash("token_1");
|
||||
let h2 = token_hash("token_2");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_hash_is_64_hex_chars() {
|
||||
let h = token_hash("test");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! JWT authentication, password hashing, CSRF protection, rate limiting, and auth middleware.
|
||||
mod companion_token;
|
||||
mod cookie;
|
||||
mod csrf;
|
||||
mod error;
|
||||
mod extract;
|
||||
mod jwt;
|
||||
pub mod middleware;
|
||||
mod password;
|
||||
pub mod qr_token;
|
||||
mod rate_limit;
|
||||
mod routes;
|
||||
mod security;
|
||||
pub mod trust;
|
||||
mod validation;
|
||||
|
||||
// Error type
|
||||
pub use error::AuthError;
|
||||
|
||||
// JWT service
|
||||
pub use jwt::{JwtService, TokenPayload, generate_random_hex_secret, generate_random_secret_string, resolve_jwt_secret};
|
||||
|
||||
// Per-companion API token (Remote front door)
|
||||
pub use companion_token::{CompanionTokenValidator, token_sha256_hex};
|
||||
|
||||
// Password service
|
||||
pub use password::{
|
||||
dummy_password_hash, generate_password, generate_user_credentials, hash_password, verify_password,
|
||||
verify_password_timed,
|
||||
};
|
||||
|
||||
// Validation
|
||||
pub use validation::{validate_password, validate_username};
|
||||
|
||||
// Rate limiting
|
||||
pub use rate_limit::{
|
||||
RateLimiter, api_rate_limit_middleware, auth_rate_limit_middleware, authenticated_action_rate_limit_middleware,
|
||||
};
|
||||
|
||||
// Token / IP extraction
|
||||
pub use extract::{
|
||||
extract_client_ip, extract_client_ip_from_headers, extract_cookie_value, extract_token_from_headers,
|
||||
extract_token_from_ws_headers,
|
||||
};
|
||||
|
||||
// Cookie configuration
|
||||
pub use cookie::CookieConfig;
|
||||
|
||||
// Security headers
|
||||
pub use security::security_headers_middleware;
|
||||
|
||||
// CSRF protection
|
||||
pub use csrf::csrf_middleware;
|
||||
|
||||
// Auth middleware
|
||||
pub use middleware::{AuthState, CurrentUser, auth_middleware, require_admin_middleware};
|
||||
|
||||
// Trust resolution (local-trust secret, auth policy)
|
||||
pub use trust::{
|
||||
AuthPolicy, LOCAL_TRUST_HEADER, LocalTrusted, SYSTEM_USER_ID, TrustState, is_locally_trusted,
|
||||
require_local_trust_middleware, trust_resolve_middleware,
|
||||
};
|
||||
|
||||
// QR token store
|
||||
pub use qr_token::QrTokenStore;
|
||||
|
||||
// Routes
|
||||
pub use routes::{AuthRouterState, auth_routes};
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_db::IUserRepository;
|
||||
|
||||
use crate::JwtService;
|
||||
use crate::extract::extract_token_from_headers;
|
||||
|
||||
/// Authenticated user injected into request extensions by the auth middleware.
|
||||
///
|
||||
/// Route handlers extract this from `request.extensions()` to identify
|
||||
/// the current user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CurrentUser {
|
||||
/// User ID from the database.
|
||||
pub id: String,
|
||||
/// Username.
|
||||
pub username: String,
|
||||
/// User role: 'admin' or 'user'
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Shared state for the authentication middleware.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub jwt_service: Arc<JwtService>,
|
||||
pub user_repo: Arc<dyn IUserRepository>,
|
||||
}
|
||||
|
||||
/// Authentication middleware that verifies JWT tokens and injects `CurrentUser`.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. If the global trust middleware already resolved this request as
|
||||
/// locally-trusted (NoAuth, or a valid local-trust secret), it has already
|
||||
/// injected [`CurrentUser`] — pass through unchanged.
|
||||
/// 2. Otherwise extract bearer token from `Authorization` header or
|
||||
/// `nomifun-session` cookie
|
||||
/// 3. Verify JWT signature, expiration, and blacklist
|
||||
/// 4. Look up user in the database to ensure they still exist
|
||||
/// 5. Insert [`CurrentUser`] into request extensions
|
||||
///
|
||||
/// Returns HTTP 403 for any authentication failure (per API spec).
|
||||
///
|
||||
/// Use with `axum::middleware::from_fn_with_state`.
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AuthState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
// Locally-trusted requests are resolved upstream by `trust_resolve_middleware`,
|
||||
// which injects the system user. Honor that and skip JWT verification.
|
||||
if request.extensions().get::<CurrentUser>().is_some() {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
let token = extract_token_from_headers(request.headers())
|
||||
.ok_or_else(|| AppError::Forbidden("Authentication required".into()))?;
|
||||
|
||||
let payload = state.jwt_service.verify(&token).map_err(|e| {
|
||||
tracing::debug!("Token verification failed: {e}");
|
||||
AppError::Forbidden("Invalid or expired token".into())
|
||||
})?;
|
||||
|
||||
let _user = state
|
||||
.user_repo
|
||||
.find_by_id(&payload.user_id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?
|
||||
.ok_or_else(|| AppError::Forbidden("User not found".into()))?;
|
||||
|
||||
request.extensions_mut().insert(CurrentUser {
|
||||
id: payload.user_id,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
});
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Admin-only middleware that requires the authenticated user to have admin role.
|
||||
///
|
||||
/// Must be applied AFTER `auth_middleware` (requires `CurrentUser` extension).
|
||||
///
|
||||
/// Returns HTTP 403 if the user is not an admin.
|
||||
///
|
||||
/// Use with `axum::middleware::from_fn`.
|
||||
pub async fn require_admin_middleware(
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let current_user = request
|
||||
.extensions()
|
||||
.get::<CurrentUser>()
|
||||
.ok_or_else(|| AppError::Internal("require_admin called without auth_middleware".into()))?
|
||||
.clone();
|
||||
|
||||
if current_user.role != "admin" {
|
||||
return Err(AppError::Forbidden("Admin access required".into()));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::AuthError;
|
||||
|
||||
/// bcrypt cost factor (higher = slower but more secure).
|
||||
const BCRYPT_COST: u32 = 12;
|
||||
|
||||
/// Minimum time for password verification to prevent timing attacks.
|
||||
const MIN_VERIFY_DURATION: Duration = Duration::from_millis(50);
|
||||
|
||||
// Character sets for credential generation
|
||||
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
|
||||
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const DIGITS: &[u8] = b"0123456789";
|
||||
const SPECIAL: &[u8] = b"!@#$%^&*";
|
||||
const ALPHANUMERIC_LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const ALL_PASSWORD_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
|
||||
|
||||
/// Pre-computed dummy hash for timing attack prevention.
|
||||
static DUMMY_HASH: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Hash a password using bcrypt with cost factor 12.
|
||||
///
|
||||
/// **Note**: This is a CPU-intensive blocking operation. In async contexts,
|
||||
/// wrap in `tokio::task::spawn_blocking`.
|
||||
pub fn hash_password(password: &str) -> Result<String, AuthError> {
|
||||
bcrypt::hash(password, BCRYPT_COST).map_err(|e| AuthError::HashError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Verify a password against a bcrypt hash.
|
||||
///
|
||||
/// Returns `true` if the password matches, `false` otherwise.
|
||||
/// bcrypt internally uses constant-time comparison.
|
||||
///
|
||||
/// **Note**: This is a CPU-intensive blocking operation.
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AuthError> {
|
||||
bcrypt::verify(password, hash).map_err(|e| AuthError::HashError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Verify a password with a guaranteed minimum execution time of 50ms.
|
||||
///
|
||||
/// Runs bcrypt verification on a blocking thread pool and pads the response
|
||||
/// time to at least 50ms. This prevents timing attacks that could distinguish
|
||||
/// "user exists + wrong password" from "user doesn't exist".
|
||||
pub async fn verify_password_timed(password: &str, hash: &str) -> Result<bool, AuthError> {
|
||||
let start = Instant::now();
|
||||
let password = password.to_owned();
|
||||
let hash = hash.to_owned();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || verify_password(&password, &hash))
|
||||
.await
|
||||
.map_err(|e| AuthError::HashError(format!("Task join error: {e}")))?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed < MIN_VERIFY_DURATION {
|
||||
tokio::time::sleep(MIN_VERIFY_DURATION - elapsed).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get a pre-computed bcrypt hash for timing attack prevention.
|
||||
///
|
||||
/// When a login attempt references a non-existent user, verify the supplied
|
||||
/// password against this dummy hash to consume the same amount of time as
|
||||
/// a real verification.
|
||||
pub fn dummy_password_hash() -> &'static str {
|
||||
DUMMY_HASH.get_or_init(|| {
|
||||
// bcrypt hash of a fixed dummy input. This cannot fail for valid input;
|
||||
// if it does, the bcrypt implementation is fundamentally broken.
|
||||
bcrypt::hash("__nomifun_dummy_password__", BCRYPT_COST).expect("bcrypt hash of constant input must succeed")
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate random user credentials for auto-bootstrap scenarios.
|
||||
///
|
||||
/// Returns `(username, password)` where:
|
||||
/// - username: 6-8 lowercase alphanumeric characters
|
||||
/// - password: 12-17 mixed characters (upper, lower, digits, special)
|
||||
pub fn generate_user_credentials() -> (String, String) {
|
||||
let username_len = random_range(6, 9);
|
||||
let password_len = random_range(12, 18);
|
||||
|
||||
let username = random_string(username_len, ALPHANUMERIC_LOWER);
|
||||
let password = generate_strong_password(password_len);
|
||||
|
||||
(username, password)
|
||||
}
|
||||
|
||||
/// Generate a strong random password suitable for WebUI admin reset.
|
||||
///
|
||||
/// Guarantees ≥1 character from each category (upper, lower, digit, special)
|
||||
/// and fills remaining slots from a mixed charset.
|
||||
pub fn generate_password(len: usize) -> String {
|
||||
// Enforce minimum length of 4 to satisfy the four-category guarantee.
|
||||
generate_strong_password(len.max(4))
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
/// Fill a buffer with cryptographically random bytes.
|
||||
///
|
||||
/// Panics if the OS entropy source is unavailable. This mirrors the behavior
|
||||
/// of `uuid::Uuid::now_v7()` used in `nomifun-common`.
|
||||
fn fill_random(buf: &mut [u8]) {
|
||||
getrandom::getrandom(buf).expect("OS entropy source unavailable");
|
||||
}
|
||||
|
||||
/// Generate a random integer in `[min, max_exclusive)`.
|
||||
fn random_range(min: usize, max_exclusive: usize) -> usize {
|
||||
let range = max_exclusive - min;
|
||||
let mut buf = [0u8; 4];
|
||||
fill_random(&mut buf);
|
||||
min + (u32::from_le_bytes(buf) as usize) % range
|
||||
}
|
||||
|
||||
/// Pick a random byte from the given charset.
|
||||
fn random_from(charset: &[u8]) -> u8 {
|
||||
let mut buf = [0u8; 1];
|
||||
fill_random(&mut buf);
|
||||
charset[buf[0] as usize % charset.len()]
|
||||
}
|
||||
|
||||
/// Generate a random string of the given length from the charset.
|
||||
fn random_string(len: usize, charset: &[u8]) -> String {
|
||||
let mut buf = vec![0u8; len];
|
||||
fill_random(&mut buf);
|
||||
buf.iter()
|
||||
.map(|b| charset[*b as usize % charset.len()] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a strong password with guaranteed character variety.
|
||||
fn generate_strong_password(len: usize) -> String {
|
||||
let mut chars = Vec::with_capacity(len);
|
||||
|
||||
// Guarantee at least one character from each category
|
||||
chars.push(random_from(UPPER));
|
||||
chars.push(random_from(LOWER));
|
||||
chars.push(random_from(DIGITS));
|
||||
chars.push(random_from(SPECIAL));
|
||||
|
||||
// Fill remaining positions from the full charset
|
||||
for _ in 4..len {
|
||||
chars.push(random_from(ALL_PASSWORD_CHARS));
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
let mut shuffle_bytes = vec![0u8; chars.len()];
|
||||
fill_random(&mut shuffle_bytes);
|
||||
for i in (1..chars.len()).rev() {
|
||||
let j = shuffle_bytes[i] as usize % (i + 1);
|
||||
chars.swap(i, j);
|
||||
}
|
||||
|
||||
chars.iter().map(|&b| b as char).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::validation::{validate_password, validate_username};
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify_correct_password() {
|
||||
let hash = hash_password("my_secure_password").unwrap();
|
||||
assert!(verify_password("my_secure_password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_wrong_password() {
|
||||
let hash = hash_password("correct_password").unwrap();
|
||||
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_produces_bcrypt_format() {
|
||||
let hash = hash_password("test_password").unwrap();
|
||||
assert!(hash.starts_with("$2b$12$"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dummy_hash_is_valid_bcrypt() {
|
||||
let hash = dummy_password_hash();
|
||||
assert!(hash.starts_with("$2b$12$"));
|
||||
assert!(!verify_password("random", hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dummy_hash_matches_dummy_input() {
|
||||
let hash = dummy_password_hash();
|
||||
assert!(verify_password("__nomifun_dummy_password__", hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_credentials_produces_valid_username() {
|
||||
for _ in 0..10 {
|
||||
let (username, _) = generate_user_credentials();
|
||||
assert!(
|
||||
username.len() >= 6 && username.len() <= 8,
|
||||
"username length out of range: {}",
|
||||
username.len()
|
||||
);
|
||||
assert!(
|
||||
validate_username(&username).is_ok(),
|
||||
"generated username failed validation: {username}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_credentials_produces_valid_password() {
|
||||
for _ in 0..10 {
|
||||
let (_, password) = generate_user_credentials();
|
||||
assert!(
|
||||
password.len() >= 12 && password.len() <= 17,
|
||||
"password length out of range: {}",
|
||||
password.len()
|
||||
);
|
||||
assert!(
|
||||
validate_password(&password).is_ok(),
|
||||
"generated password failed validation: {password}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_credentials_has_character_variety() {
|
||||
for _ in 0..10 {
|
||||
let (_, password) = generate_user_credentials();
|
||||
let has_upper = password.bytes().any(|b| b.is_ascii_uppercase());
|
||||
let has_lower = password.bytes().any(|b| b.is_ascii_lowercase());
|
||||
let has_digit = password.bytes().any(|b| b.is_ascii_digit());
|
||||
let has_special = password.bytes().any(|b| SPECIAL.contains(&b));
|
||||
assert!(has_upper, "password missing uppercase: {password}");
|
||||
assert!(has_lower, "password missing lowercase: {password}");
|
||||
assert!(has_digit, "password missing digit: {password}");
|
||||
assert!(has_special, "password missing special char: {password}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_timed_correct_password() {
|
||||
let hash = hash_password("test_password").unwrap();
|
||||
let start = Instant::now();
|
||||
let result = verify_password_timed("test_password", &hash).await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(result.unwrap());
|
||||
assert!(
|
||||
elapsed >= MIN_VERIFY_DURATION,
|
||||
"verification took {elapsed:?}, expected >= {MIN_VERIFY_DURATION:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_timed_wrong_password() {
|
||||
let hash = hash_password("correct").unwrap();
|
||||
let start = Instant::now();
|
||||
let result = verify_password_timed("wrong", &hash).await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(!result.unwrap());
|
||||
assert!(elapsed >= MIN_VERIFY_DURATION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// QR token time-to-live: 5 minutes.
|
||||
const QR_TOKEN_TTL_MS: i64 = 5 * 60 * 1000;
|
||||
|
||||
/// Random token length in bytes (produces 64-char hex string).
|
||||
const QR_TOKEN_BYTES: usize = 32;
|
||||
|
||||
/// Internal data for a QR login token.
|
||||
struct QrTokenData {
|
||||
created_at_ms: i64,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
/// In-memory QR login token store with automatic expiration.
|
||||
///
|
||||
/// Tokens are one-time-use and expire after 5 minutes.
|
||||
/// Thread-safe via `DashMap`.
|
||||
pub struct QrTokenStore {
|
||||
tokens: DashMap<String, QrTokenData>,
|
||||
}
|
||||
|
||||
impl Default for QrTokenStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl QrTokenStore {
|
||||
pub fn new() -> Self {
|
||||
Self { tokens: DashMap::new() }
|
||||
}
|
||||
|
||||
/// Generate a new QR login token and store it.
|
||||
///
|
||||
/// Returns the 64-character hex token string.
|
||||
pub fn generate(&self) -> String {
|
||||
self.generate_with_expiry().0
|
||||
}
|
||||
|
||||
/// Generate a new QR login token and return it along with its expiry timestamp (ms).
|
||||
///
|
||||
/// Returns `(token, expires_at_ms)` where `expires_at_ms` is the absolute
|
||||
/// Unix time in milliseconds when the token becomes invalid.
|
||||
pub fn generate_with_expiry(&self) -> (String, i64) {
|
||||
let mut buf = [0u8; QR_TOKEN_BYTES];
|
||||
getrandom::getrandom(&mut buf).expect("OS entropy source unavailable");
|
||||
|
||||
let mut token = String::with_capacity(QR_TOKEN_BYTES * 2);
|
||||
for byte in buf {
|
||||
let _ = write!(token, "{byte:02x}");
|
||||
}
|
||||
|
||||
let created_at_ms = nomifun_common::now_ms();
|
||||
self.tokens.insert(
|
||||
token.clone(),
|
||||
QrTokenData {
|
||||
created_at_ms,
|
||||
used: false,
|
||||
},
|
||||
);
|
||||
|
||||
(token, created_at_ms + QR_TOKEN_TTL_MS)
|
||||
}
|
||||
|
||||
/// Validate and consume a QR token (one-time use).
|
||||
///
|
||||
/// Checks existence, expiry (5 min), and used status atomically.
|
||||
pub fn validate_and_consume(&self, token: &str) -> Result<(), AppError> {
|
||||
let mut entry = self
|
||||
.tokens
|
||||
.get_mut(token)
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid QR token".into()))?;
|
||||
|
||||
if entry.used {
|
||||
return Err(AppError::Unauthorized("QR token already used".into()));
|
||||
}
|
||||
|
||||
let now = nomifun_common::now_ms();
|
||||
let elapsed_ms = now.saturating_sub(entry.created_at_ms);
|
||||
if elapsed_ms > QR_TOKEN_TTL_MS {
|
||||
return Err(AppError::Unauthorized("QR token expired".into()));
|
||||
}
|
||||
|
||||
entry.used = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove expired tokens to prevent unbounded memory growth.
|
||||
pub fn cleanup(&self) {
|
||||
let now = nomifun_common::now_ms();
|
||||
self.tokens
|
||||
.retain(|_, data| now.saturating_sub(data.created_at_ms) <= QR_TOKEN_TTL_MS);
|
||||
}
|
||||
|
||||
/// Start a background task that cleans up expired tokens periodically.
|
||||
pub fn start_cleanup_task(self: &Arc<Self>, interval: Duration) {
|
||||
let store = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
store.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of stored tokens (for monitoring/testing).
|
||||
pub fn token_count(&self) -> usize {
|
||||
self.tokens.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_produces_64_hex_chars() {
|
||||
let store = QrTokenStore::new();
|
||||
let token = store.generate();
|
||||
assert_eq!(token.len(), 64);
|
||||
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_produces_unique_tokens() {
|
||||
let store = QrTokenStore::new();
|
||||
let t1 = store.generate();
|
||||
let t2 = store.generate();
|
||||
assert_ne!(t1, t2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_increments_count() {
|
||||
let store = QrTokenStore::new();
|
||||
assert_eq!(store.token_count(), 0);
|
||||
store.generate();
|
||||
assert_eq!(store.token_count(), 1);
|
||||
store.generate();
|
||||
assert_eq!(store.token_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_and_consume_valid_token() {
|
||||
let store = QrTokenStore::new();
|
||||
let token = store.generate();
|
||||
assert!(store.validate_and_consume(&token).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_nonexistent_token_fails() {
|
||||
let store = QrTokenStore::new();
|
||||
let err = store.validate_and_consume("nonexistent").unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_already_used_token_fails() {
|
||||
let store = QrTokenStore::new();
|
||||
let token = store.generate();
|
||||
store.validate_and_consume(&token).unwrap();
|
||||
|
||||
let err = store.validate_and_consume(&token).unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_expired_token_fails() {
|
||||
let store = QrTokenStore::new();
|
||||
// Manually insert an expired token
|
||||
store.tokens.insert(
|
||||
"expired_token".to_owned(),
|
||||
QrTokenData {
|
||||
created_at_ms: 1000, // very old
|
||||
used: false,
|
||||
},
|
||||
);
|
||||
|
||||
let err = store.validate_and_consume("expired_token").unwrap_err();
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_expired_tokens() {
|
||||
let store = QrTokenStore::new();
|
||||
// Insert an expired token
|
||||
store.tokens.insert(
|
||||
"old".to_owned(),
|
||||
QrTokenData {
|
||||
created_at_ms: 1000,
|
||||
used: false,
|
||||
},
|
||||
);
|
||||
// Insert a fresh token
|
||||
store.generate();
|
||||
assert_eq!(store.token_count(), 2);
|
||||
|
||||
store.cleanup();
|
||||
assert_eq!(store.token_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_fresh_tokens() {
|
||||
let store = QrTokenStore::new();
|
||||
store.generate();
|
||||
store.cleanup();
|
||||
assert_eq!(store.token_count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use dashmap::DashMap;
|
||||
|
||||
use nomifun_common::AppError;
|
||||
|
||||
use crate::extract::extract_client_ip;
|
||||
use crate::middleware::CurrentUser;
|
||||
|
||||
/// Rate limit entry tracking request count within a fixed time window.
|
||||
struct RateLimitEntry {
|
||||
count: u32,
|
||||
reset_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Fixed-window rate limiter backed by a concurrent `DashMap`.
|
||||
///
|
||||
/// Thread-safe for use across multiple request handlers.
|
||||
pub struct RateLimiter {
|
||||
entries: DashMap<String, RateLimitEntry>,
|
||||
max_requests: u32,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// Create a rate limiter with the given capacity and window duration.
|
||||
pub fn new(max_requests: u32, window: Duration) -> Self {
|
||||
Self {
|
||||
entries: DashMap::new(),
|
||||
max_requests,
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth rate limiter: 5 failed attempts per 15-minute window.
|
||||
pub fn auth() -> Self {
|
||||
Self::new(5, Duration::from_secs(15 * 60))
|
||||
}
|
||||
|
||||
/// API rate limiter: 60 requests per 1-minute window.
|
||||
pub fn api() -> Self {
|
||||
Self::new(60, Duration::from_secs(60))
|
||||
}
|
||||
|
||||
/// Authenticated action limiter: 20 requests per 1-minute window.
|
||||
pub fn authenticated_action() -> Self {
|
||||
Self::new(20, Duration::from_secs(60))
|
||||
}
|
||||
|
||||
/// Check if the key is rate limited without modifying state.
|
||||
///
|
||||
/// For the auth rate limiter: check first, record failure later
|
||||
/// via [`record_attempt`](Self::record_attempt).
|
||||
pub fn check(&self, key: &str) -> Result<(), AppError> {
|
||||
let now = now_ms();
|
||||
if let Some(entry) = self.entries.get(key)
|
||||
&& now < entry.reset_time_ms
|
||||
&& entry.count >= self.max_requests
|
||||
{
|
||||
return Err(AppError::RateLimited);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check rate limit and increment the counter atomically.
|
||||
///
|
||||
/// For API and authenticated-action rate limiters.
|
||||
pub fn check_and_increment(&self, key: &str) -> Result<(), AppError> {
|
||||
let now = now_ms();
|
||||
let window_ms = self.window.as_millis() as u64;
|
||||
|
||||
let mut entry = self.entries.entry(key.to_owned()).or_insert(RateLimitEntry {
|
||||
count: 0,
|
||||
reset_time_ms: now + window_ms,
|
||||
});
|
||||
|
||||
if now >= entry.reset_time_ms {
|
||||
entry.count = 0;
|
||||
entry.reset_time_ms = now + window_ms;
|
||||
}
|
||||
|
||||
if entry.count >= self.max_requests {
|
||||
return Err(AppError::RateLimited);
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a single failed attempt without checking the limit.
|
||||
///
|
||||
/// Used by the auth rate limiter after a failed login response.
|
||||
pub fn record_attempt(&self, key: &str) {
|
||||
let now = now_ms();
|
||||
let window_ms = self.window.as_millis() as u64;
|
||||
|
||||
let mut entry = self.entries.entry(key.to_owned()).or_insert(RateLimitEntry {
|
||||
count: 0,
|
||||
reset_time_ms: now + window_ms,
|
||||
});
|
||||
|
||||
if now >= entry.reset_time_ms {
|
||||
entry.count = 0;
|
||||
entry.reset_time_ms = now + window_ms;
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
/// Remove expired entries to prevent unbounded memory growth.
|
||||
pub fn cleanup(&self) {
|
||||
let now = now_ms();
|
||||
self.entries.retain(|_, entry| now < entry.reset_time_ms);
|
||||
}
|
||||
|
||||
/// Start a background task that cleans up expired entries periodically.
|
||||
pub fn start_cleanup_task(self: &Arc<Self>, interval: Duration) {
|
||||
let limiter = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
limiter.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of tracked keys (for monitoring/testing).
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Client identity for rate limiting. Prefers the real TCP peer address from
|
||||
/// `ConnectInfo` (set on the desktop LAN listener via
|
||||
/// `into_make_service_with_connect_info`) over the spoofable
|
||||
/// `X-Forwarded-For` / `X-Real-IP` headers. Falls back to the header value when
|
||||
/// connect-info is absent (loopback / standalone-web / tests — byte-identical).
|
||||
fn rate_limit_ip(request: &Request) -> String {
|
||||
if let Some(axum::extract::ConnectInfo(addr)) =
|
||||
request.extensions().get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
{
|
||||
return addr.ip().to_string();
|
||||
}
|
||||
extract_client_ip(request)
|
||||
}
|
||||
|
||||
/// Auth rate limit middleware: 5 failed attempts per 15 minutes per IP.
|
||||
///
|
||||
/// Pre-checks the limit; records failures only for non-success responses
|
||||
/// (skips successful requests per API spec).
|
||||
pub async fn auth_rate_limit_middleware(
|
||||
State(limiter): State<Arc<RateLimiter>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let ip = rate_limit_ip(&request);
|
||||
limiter.check(&ip)?;
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
limiter.record_attempt(&ip);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// API rate limit middleware: 60 requests per minute per IP.
|
||||
pub async fn api_rate_limit_middleware(
|
||||
State(limiter): State<Arc<RateLimiter>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let ip = rate_limit_ip(&request);
|
||||
limiter.check_and_increment(&ip)?;
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Authenticated action rate limit middleware: 20 requests per minute.
|
||||
///
|
||||
/// Prefers user ID from [`CurrentUser`] extension (set by auth middleware),
|
||||
/// falls back to client IP.
|
||||
pub async fn authenticated_action_rate_limit_middleware(
|
||||
State(limiter): State<Arc<RateLimiter>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let key = request
|
||||
.extensions()
|
||||
.get::<CurrentUser>()
|
||||
.map(|u| format!("user:{}", u.id))
|
||||
.unwrap_or_else(|| format!("ip:{}", rate_limit_ip(&request)));
|
||||
limiter.check_and_increment(&key)?;
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_limiter_allows_requests() {
|
||||
let limiter = RateLimiter::new(3, Duration::from_secs(60));
|
||||
assert!(limiter.check("key").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_and_increment_enforces_limit() {
|
||||
let limiter = RateLimiter::new(2, Duration::from_secs(60));
|
||||
assert!(limiter.check_and_increment("key").is_ok());
|
||||
assert!(limiter.check_and_increment("key").is_ok());
|
||||
assert!(limiter.check_and_increment("key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_keys_have_independent_limits() {
|
||||
let limiter = RateLimiter::new(1, Duration::from_secs(60));
|
||||
assert!(limiter.check_and_increment("key_a").is_ok());
|
||||
assert!(limiter.check_and_increment("key_b").is_ok());
|
||||
assert!(limiter.check_and_increment("key_a").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_does_not_increment() {
|
||||
let limiter = RateLimiter::new(1, Duration::from_secs(60));
|
||||
// check() alone never increments
|
||||
assert!(limiter.check("key").is_ok());
|
||||
assert!(limiter.check("key").is_ok());
|
||||
// One recorded attempt fills the quota
|
||||
limiter.record_attempt("key");
|
||||
assert!(limiter.check("key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_attempt_increments_counter() {
|
||||
let limiter = RateLimiter::new(2, Duration::from_secs(60));
|
||||
limiter.record_attempt("key");
|
||||
limiter.record_attempt("key");
|
||||
assert!(limiter.check("key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_window_resets_count() {
|
||||
let limiter = RateLimiter::new(1, Duration::from_millis(50));
|
||||
assert!(limiter.check_and_increment("key").is_ok());
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// Window expired → counter reset
|
||||
assert!(limiter.check_and_increment("key").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_window_allows_check() {
|
||||
let limiter = RateLimiter::new(1, Duration::from_millis(50));
|
||||
limiter.record_attempt("key");
|
||||
assert!(limiter.check("key").is_err());
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// Window expired → check passes
|
||||
assert!(limiter.check("key").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_expired_entries() {
|
||||
let limiter = RateLimiter::new(10, Duration::from_millis(50));
|
||||
limiter.check_and_increment("key").unwrap();
|
||||
assert_eq!(limiter.entry_count(), 1);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
limiter.cleanup();
|
||||
assert_eq!(limiter.entry_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_active_entries() {
|
||||
let limiter = RateLimiter::new(10, Duration::from_secs(60));
|
||||
limiter.check_and_increment("key").unwrap();
|
||||
limiter.cleanup();
|
||||
assert_eq!(limiter.entry_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_auth_limit_is_five() {
|
||||
let limiter = RateLimiter::auth();
|
||||
for _ in 0..5 {
|
||||
assert!(limiter.check_and_increment("ip").is_ok());
|
||||
}
|
||||
assert!(limiter.check_and_increment("ip").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_api_limit_is_sixty() {
|
||||
let limiter = RateLimiter::api();
|
||||
for _ in 0..60 {
|
||||
assert!(limiter.check_and_increment("ip").is_ok());
|
||||
}
|
||||
assert!(limiter.check_and_increment("ip").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_authenticated_action_limit_is_twenty() {
|
||||
let limiter = RateLimiter::authenticated_action();
|
||||
for _ in 0..20 {
|
||||
assert!(limiter.check_and_increment("user:1").is_ok());
|
||||
}
|
||||
assert!(limiter.check_and_increment("user:1").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
use axum::extract::Request;
|
||||
use axum::http::header::{HeaderValue, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS, X_XSS_PROTECTION};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
fn allows_embedding(path: &str) -> bool {
|
||||
let mut segments = path.trim_start_matches('/').split('/');
|
||||
matches!(
|
||||
(segments.next(), segments.next(), segments.next(), segments.next(),),
|
||||
(Some("api"), Some("extensions"), Some(_extension_name), Some("assets"))
|
||||
)
|
||||
}
|
||||
|
||||
/// Middleware that adds security response headers to every response.
|
||||
///
|
||||
/// Headers set:
|
||||
/// - `X-Frame-Options: DENY` — prevent clickjacking on non-embeddable routes
|
||||
/// - `X-Content-Type-Options: nosniff` — prevent MIME sniffing
|
||||
/// - `X-XSS-Protection: 1; mode=block` — enable XSS filter
|
||||
/// - `Referrer-Policy: strict-origin-when-cross-origin` — limit referrer leakage
|
||||
pub async fn security_headers_middleware(request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path().to_string();
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
|
||||
if !allows_embedding(&path) {
|
||||
headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
}
|
||||
headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||
headers.insert(X_XSS_PROTECTION, HeaderValue::from_static("1; mode=block"));
|
||||
headers.insert(
|
||||
REFERRER_POLICY,
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::routing::get;
|
||||
use axum::{Router, middleware};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_security_headers_present() {
|
||||
let app = Router::new()
|
||||
.route("/test", get(|| async { "ok" }))
|
||||
.layer(middleware::from_fn(security_headers_middleware));
|
||||
|
||||
let response = app
|
||||
.oneshot(axum::http::Request::builder().uri("/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
assert_eq!(response.headers().get("x-content-type-options").unwrap(), "nosniff");
|
||||
assert_eq!(response.headers().get("x-xss-protection").unwrap(), "1; mode=block");
|
||||
assert_eq!(
|
||||
response.headers().get("referrer-policy").unwrap(),
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn security_headers_on_error_responses() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/error",
|
||||
get(|| async { axum::http::StatusCode::INTERNAL_SERVER_ERROR }),
|
||||
)
|
||||
.layer(middleware::from_fn(security_headers_middleware));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/error")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
// Security headers still present even on error responses
|
||||
assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_asset_routes_omit_frame_deny_header() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/extensions/hello/assets/settings/index.html",
|
||||
get(|| async { "ok" }),
|
||||
)
|
||||
.layer(middleware::from_fn(security_headers_middleware));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/extensions/hello/assets/settings/index.html")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(response.headers().get("x-frame-options").is_none());
|
||||
assert_eq!(response.headers().get("x-content-type-options").unwrap(), "nosniff");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Local-trust resolution: how a backend distinguishes its own desktop webview
|
||||
//! (no login) from a remote LAN browser (must log in).
|
||||
//!
|
||||
//! The single source of truth is [`AuthPolicy`] (replacing the old
|
||||
//! `local: bool`). Trust is decided PER REQUEST by [`trust_resolve_middleware`],
|
||||
//! which runs as the outermost application middleware — before CSRF and the
|
||||
//! per-route auth middleware — so both can read the [`LocalTrusted`] marker and
|
||||
//! the injected [`CurrentUser`] it leaves in the request extensions.
|
||||
//!
|
||||
//! The desktop's own webview proves it is the trusted local client by
|
||||
//! presenting a per-boot secret in the [`LOCAL_TRUST_HEADER`] header (and, for
|
||||
//! the WebSocket upgrade where browsers cannot set custom headers, as a
|
||||
//! `Sec-WebSocket-Protocol` value — see `extract_token_from_ws_headers`). The
|
||||
//! secret identifies the *process* the desktop injected it into, NOT "any
|
||||
//! loopback connection", so other local OS accounts and same-host reverse
|
||||
//! proxies are not trusted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
use nomifun_common::AppError;
|
||||
|
||||
use crate::middleware::CurrentUser;
|
||||
|
||||
/// The privileged identity injected for trusted (local) requests.
|
||||
pub const SYSTEM_USER_ID: &str = "system_default_user";
|
||||
|
||||
/// HTTP header the desktop webview presents to prove it is the trusted local
|
||||
/// client. Value = the per-boot local-trust secret. Named with a `token`-ish
|
||||
/// shape so request-logging redaction patterns mask it.
|
||||
pub const LOCAL_TRUST_HEADER: &str = "x-nomi-local-trust";
|
||||
|
||||
/// Authentication policy for a backend instance — the single source of truth
|
||||
/// that replaces the former scattered `local: bool`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum AuthPolicy {
|
||||
/// Authentication fully disabled; every request is `system_default_user`.
|
||||
/// Used by `--insecure-no-auth` (dev / `dev:webui`).
|
||||
NoAuth,
|
||||
/// JWT required for every client. Standalone `nomifun-web` default.
|
||||
Required,
|
||||
/// JWT required for everyone EXCEPT requests bearing the per-boot
|
||||
/// local-trust secret (the desktop's own webview), which are
|
||||
/// `system_default_user`. Used by the desktop shell.
|
||||
TrustLocalToken,
|
||||
}
|
||||
|
||||
impl AuthPolicy {
|
||||
/// No authentication at all (every request is the system user).
|
||||
pub fn is_no_auth(self) -> bool {
|
||||
matches!(self, AuthPolicy::NoAuth)
|
||||
}
|
||||
|
||||
/// Whether the desktop's own cross-origin webview may connect. Its document
|
||||
/// origin (`tauri://` / `http://tauri.localhost`) differs from the loopback
|
||||
/// API port, so permissive CORS is required for these policies.
|
||||
pub fn allows_local_webview(self) -> bool {
|
||||
matches!(self, AuthPolicy::NoAuth | AuthPolicy::TrustLocalToken)
|
||||
}
|
||||
|
||||
/// Whether boot-time admin credential pre-seeding applies. Only the
|
||||
/// standalone authenticated host pre-seeds; NoAuth needs no admin and the
|
||||
/// desktop provisions a password lazily when remote access is first enabled.
|
||||
pub fn requires_admin_provisioning(self) -> bool {
|
||||
matches!(self, AuthPolicy::Required)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker inserted into request extensions when a request has been granted
|
||||
/// local trust (NoAuth, or a valid local-trust secret). Read by the CSRF
|
||||
/// middleware (to skip — header-trusted requests are not cookie-ambient).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LocalTrusted;
|
||||
|
||||
/// State for [`trust_resolve_middleware`].
|
||||
#[derive(Clone)]
|
||||
pub struct TrustState {
|
||||
pub policy: AuthPolicy,
|
||||
/// The per-boot secret. Only `Some` under [`AuthPolicy::TrustLocalToken`].
|
||||
pub local_trust_secret: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
/// Constant-time comparison of two strings. Length may leak (the secret is
|
||||
/// fixed-length hex); the byte contents do not.
|
||||
fn ct_eq(a: &str, b: &str) -> bool {
|
||||
let a = a.as_bytes();
|
||||
let b = b.as_bytes();
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
fn header_secret_matches(headers: &HeaderMap, secret: &str) -> bool {
|
||||
headers
|
||||
.get(LOCAL_TRUST_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|presented| ct_eq(presented, secret))
|
||||
}
|
||||
|
||||
/// Resolve whether the given headers carry valid local trust under `state`.
|
||||
/// Shared by the HTTP middleware and the WebSocket validator.
|
||||
pub fn is_locally_trusted(state: &TrustState, headers: &HeaderMap) -> bool {
|
||||
match state.policy {
|
||||
AuthPolicy::NoAuth => true,
|
||||
AuthPolicy::TrustLocalToken => state
|
||||
.local_trust_secret
|
||||
.as_deref()
|
||||
.is_some_and(|secret| header_secret_matches(headers, secret)),
|
||||
AuthPolicy::Required => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Outermost application middleware. Resolves local trust BEFORE CSRF and the
|
||||
/// per-route auth middleware run. When trusted it injects the privileged
|
||||
/// [`CurrentUser`] plus a [`LocalTrusted`] marker; otherwise it passes through
|
||||
/// untouched so per-route auth can enforce JWT where required.
|
||||
pub async fn trust_resolve_middleware(State(state): State<TrustState>, mut request: Request, next: Next) -> Response {
|
||||
if is_locally_trusted(&state, request.headers()) {
|
||||
request.extensions_mut().insert(CurrentUser {
|
||||
id: SYSTEM_USER_ID.to_string(),
|
||||
username: SYSTEM_USER_ID.to_string(),
|
||||
role: "admin".to_string(),
|
||||
});
|
||||
request.extensions_mut().insert(LocalTrusted);
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Route-layer middleware that rejects any request not granted local trust.
|
||||
/// Applied to the `/api/webui/*` and `/api/auth/internal/*` credential routes
|
||||
/// (which sit in the otherwise-public group with no auth middleware).
|
||||
pub async fn require_local_trust_middleware(request: Request, next: Next) -> Result<Response, AppError> {
|
||||
if request.extensions().get::<LocalTrusted>().is_some() {
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
"This endpoint is only available to the local desktop client".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hdrs(secret: Option<&str>) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
if let Some(s) = secret {
|
||||
h.insert(LOCAL_TRUST_HEADER, s.parse().unwrap());
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_auth_always_trusted() {
|
||||
let st = TrustState { policy: AuthPolicy::NoAuth, local_trust_secret: None };
|
||||
assert!(is_locally_trusted(&st, &hdrs(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_never_trusted() {
|
||||
let st = TrustState { policy: AuthPolicy::Required, local_trust_secret: None };
|
||||
assert!(!is_locally_trusted(&st, &hdrs(Some("anything"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_local_token_matches_secret_only() {
|
||||
let st = TrustState {
|
||||
policy: AuthPolicy::TrustLocalToken,
|
||||
local_trust_secret: Some(Arc::from("s3cr3t-abc")),
|
||||
};
|
||||
assert!(is_locally_trusted(&st, &hdrs(Some("s3cr3t-abc"))));
|
||||
assert!(!is_locally_trusted(&st, &hdrs(Some("wrong"))));
|
||||
assert!(!is_locally_trusted(&st, &hdrs(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ct_eq_basic() {
|
||||
assert!(ct_eq("abc", "abc"));
|
||||
assert!(!ct_eq("abc", "abd"));
|
||||
assert!(!ct_eq("abc", "abcd"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use crate::error::AuthError;
|
||||
|
||||
const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||
const MIN_USERNAME_LENGTH: usize = 3;
|
||||
const MAX_USERNAME_LENGTH: usize = 32;
|
||||
|
||||
/// Common weak passwords rejected during validation.
|
||||
const WEAK_PASSWORDS: &[&str] = &["password", "12345678", "123456789", "qwertyui", "abcdefgh"];
|
||||
|
||||
/// Validate password strength.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Length: 8-128 characters
|
||||
/// - Not in the weak password blacklist (case-insensitive)
|
||||
pub fn validate_password(password: &str) -> Result<(), AuthError> {
|
||||
if password.len() < MIN_PASSWORD_LENGTH {
|
||||
return Err(AuthError::WeakPassword(format!(
|
||||
"Password must be at least {MIN_PASSWORD_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
if password.len() > MAX_PASSWORD_LENGTH {
|
||||
return Err(AuthError::WeakPassword(format!(
|
||||
"Password must not exceed {MAX_PASSWORD_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
let lower = password.to_lowercase();
|
||||
if WEAK_PASSWORDS.contains(&lower.as_str()) {
|
||||
return Err(AuthError::WeakPassword("Password is too common".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate username format.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Length: 3-32 characters
|
||||
/// - Allowed characters: `[a-zA-Z0-9_-]`
|
||||
/// - Must not start or end with `-` or `_`
|
||||
pub fn validate_username(username: &str) -> Result<(), AuthError> {
|
||||
if username.len() < MIN_USERNAME_LENGTH {
|
||||
return Err(AuthError::InvalidUsername(format!(
|
||||
"Username must be at least {MIN_USERNAME_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
if username.len() > MAX_USERNAME_LENGTH {
|
||||
return Err(AuthError::InvalidUsername(format!(
|
||||
"Username must not exceed {MAX_USERNAME_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
if !username
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
{
|
||||
return Err(AuthError::InvalidUsername(
|
||||
"Username may only contain letters, digits, underscores, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
// Safe to index: length >= 3, all ASCII
|
||||
let first = username.as_bytes()[0];
|
||||
let last = username.as_bytes()[username.len() - 1];
|
||||
if matches!(first, b'-' | b'_') || matches!(last, b'-' | b'_') {
|
||||
return Err(AuthError::InvalidUsername(
|
||||
"Username must not start or end with a hyphen or underscore".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- Password validation ---
|
||||
|
||||
#[test]
|
||||
fn valid_password() {
|
||||
assert!(validate_password("StrongP@ss1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_exactly_min_length_valid() {
|
||||
assert!(validate_password("abcDEF12").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_exactly_max_length() {
|
||||
let max = "a".repeat(128);
|
||||
assert!(validate_password(&max).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_too_short() {
|
||||
assert!(matches!(validate_password("short"), Err(AuthError::WeakPassword(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_too_long() {
|
||||
let long = "a".repeat(129);
|
||||
assert!(matches!(validate_password(&long), Err(AuthError::WeakPassword(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_password_rejected() {
|
||||
for &weak in WEAK_PASSWORDS {
|
||||
assert!(validate_password(weak).is_err(), "expected rejection for: {weak}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_password_case_insensitive() {
|
||||
assert!(validate_password("PASSWORD").is_err());
|
||||
assert!(validate_password("Password").is_err());
|
||||
}
|
||||
|
||||
// --- Username validation ---
|
||||
|
||||
#[test]
|
||||
fn valid_username() {
|
||||
assert!(validate_username("test_user-1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_alphanumeric_only() {
|
||||
assert!(validate_username("abc123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_exactly_min_length() {
|
||||
assert!(validate_username("abc").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_exactly_max_length() {
|
||||
let max = "a".repeat(32);
|
||||
assert!(validate_username(&max).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_too_short() {
|
||||
assert!(matches!(validate_username("ab"), Err(AuthError::InvalidUsername(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_too_long() {
|
||||
let long = "a".repeat(33);
|
||||
assert!(matches!(validate_username(&long), Err(AuthError::InvalidUsername(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_invalid_chars() {
|
||||
assert!(validate_username("test@user").is_err());
|
||||
assert!(validate_username("test user").is_err());
|
||||
assert!(validate_username("test.user").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_starts_with_hyphen() {
|
||||
assert!(validate_username("-test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_starts_with_underscore() {
|
||||
assert!(validate_username("_test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_ends_with_hyphen() {
|
||||
assert!(validate_username("test-").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_ends_with_underscore() {
|
||||
assert!(validate_username("test_").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_hyphen_in_middle() {
|
||||
assert!(validate_username("test-user").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_underscore_in_middle() {
|
||||
assert!(validate_username("test_user").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! JWT service integration tests.
|
||||
//!
|
||||
//! Tests the full lifecycle of JWT operations: sign, verify, blacklist, rotate.
|
||||
|
||||
use nomifun_auth::{AuthError, JwtService, resolve_jwt_secret};
|
||||
|
||||
#[test]
|
||||
fn full_lifecycle_sign_verify_blacklist() {
|
||||
let service = JwtService::new("integration_test_secret".into());
|
||||
|
||||
// Sign a token
|
||||
let token = service.sign("user_42", "testuser").unwrap();
|
||||
assert!(!token.is_empty());
|
||||
|
||||
// Verify the token
|
||||
let payload = service.verify(&token).unwrap();
|
||||
assert_eq!(payload.user_id, "user_42");
|
||||
assert_eq!(payload.username, "testuser");
|
||||
|
||||
// Blacklist the token
|
||||
service.blacklist_token(&token);
|
||||
|
||||
// Verification should now fail
|
||||
let result = service.verify(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenBlacklisted)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_rotation_invalidates_all_tokens() {
|
||||
let service = JwtService::new("original_secret".into());
|
||||
|
||||
let token1 = service.sign("user_1", "alice").unwrap();
|
||||
let token2 = service.sign("user_2", "bob").unwrap();
|
||||
|
||||
// Both tokens are valid
|
||||
assert!(service.verify(&token1).is_ok());
|
||||
assert!(service.verify(&token2).is_ok());
|
||||
|
||||
// Rotate the secret
|
||||
service.rotate_secret().unwrap();
|
||||
|
||||
// Both old tokens are now invalid
|
||||
assert!(service.verify(&token1).is_err());
|
||||
assert!(service.verify(&token2).is_err());
|
||||
|
||||
// New tokens with the new secret work
|
||||
let new_token = service.sign("user_3", "charlie").unwrap();
|
||||
let payload = service.verify(&new_token).unwrap();
|
||||
assert_eq!(payload.user_id, "user_3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_priority_order() {
|
||||
// Environment variable takes precedence
|
||||
let (secret, generated) = resolve_jwt_secret(Some("env"), Some("db"));
|
||||
assert_eq!(secret, "env");
|
||||
assert!(!generated);
|
||||
|
||||
// Database value is fallback
|
||||
let (secret, generated) = resolve_jwt_secret(None, Some("db"));
|
||||
assert_eq!(secret, "db");
|
||||
assert!(!generated);
|
||||
|
||||
// Random generation as last resort
|
||||
let (secret, generated) = resolve_jwt_secret(None, None);
|
||||
assert!(!secret.is_empty());
|
||||
assert!(generated);
|
||||
|
||||
// Generated secrets are unique
|
||||
let (secret2, _) = resolve_jwt_secret(None, None);
|
||||
assert_ne!(secret, secret2);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use axum::middleware;
|
||||
use axum::routing::{get, post};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nomifun_auth::{
|
||||
CookieConfig, CurrentUser, RateLimiter, api_rate_limit_middleware, auth_rate_limit_middleware,
|
||||
authenticated_action_rate_limit_middleware, csrf_middleware, security_headers_middleware,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// T12.1 — Security response headers
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_1_security_headers_on_get() {
|
||||
let app = Router::new()
|
||||
.route("/test", get(|| async { "ok" }))
|
||||
.layer(middleware::from_fn(security_headers_middleware));
|
||||
|
||||
let resp = app
|
||||
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
|
||||
assert_eq!(resp.headers().get("x-content-type-options").unwrap(), "nosniff");
|
||||
assert_eq!(resp.headers().get("x-xss-protection").unwrap(), "1; mode=block");
|
||||
assert_eq!(
|
||||
resp.headers().get("referrer-policy").unwrap(),
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// T12.2 — CSRF protection
|
||||
// ============================================================
|
||||
|
||||
fn csrf_app() -> Router {
|
||||
let config = Arc::new(CookieConfig {
|
||||
secure: false,
|
||||
same_site: "Lax",
|
||||
});
|
||||
Router::new()
|
||||
.route("/api/test", post(|| async { "ok" }))
|
||||
.route("/login", post(|| async { "logged in" }))
|
||||
.route("/api/auth/qr-login", post(|| async { "qr ok" }))
|
||||
.route("/get-test", get(|| async { "get ok" }))
|
||||
.layer(middleware::from_fn_with_state(config, csrf_middleware))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_get_requests_bypass_csrf() {
|
||||
let app = csrf_app();
|
||||
let resp = app
|
||||
.oneshot(Request::get("/get-test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_post_without_csrf_token_rejected() {
|
||||
let app = csrf_app();
|
||||
let resp = app
|
||||
.oneshot(Request::post("/api/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_post_with_matching_csrf_tokens_accepted() {
|
||||
let app = csrf_app();
|
||||
let token = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::post("/api/test")
|
||||
.header("cookie", format!("nomifun-csrf-token={token}"))
|
||||
.header("x-csrf-token", token)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_post_with_mismatched_csrf_tokens_rejected() {
|
||||
let app = csrf_app();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::post("/api/test")
|
||||
.header("cookie", "nomifun-csrf-token=token_a")
|
||||
.header("x-csrf-token", "token_b")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_login_exempt_from_csrf() {
|
||||
let app = csrf_app();
|
||||
let resp = app
|
||||
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_qr_login_exempt_from_csrf() {
|
||||
let app = csrf_app();
|
||||
let resp = app
|
||||
.oneshot(Request::post("/api/auth/qr-login").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_csrf_cookie_set_on_first_request() {
|
||||
let app = csrf_app();
|
||||
let resp = app
|
||||
.oneshot(Request::get("/get-test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
|
||||
assert!(set_cookie.contains("nomifun-csrf-token="));
|
||||
// NOT HttpOnly (JS must read it)
|
||||
assert!(!set_cookie.contains("HttpOnly"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Rate limiter middleware
|
||||
// ============================================================
|
||||
|
||||
fn rate_limit_app(limiter: Arc<RateLimiter>) -> Router {
|
||||
Router::new()
|
||||
.route("/test", get(|| async { "ok" }))
|
||||
.layer(middleware::from_fn_with_state(limiter, api_rate_limit_middleware))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_rate_limit_allows_within_quota() {
|
||||
let limiter = Arc::new(RateLimiter::new(3, Duration::from_secs(60)));
|
||||
let app = rate_limit_app(limiter);
|
||||
|
||||
for _ in 0..3 {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_rate_limit_rejects_over_quota() {
|
||||
let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60)));
|
||||
let app = rate_limit_app(limiter);
|
||||
|
||||
// First two pass
|
||||
for _ in 0..2 {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// Third rejected
|
||||
let resp = app
|
||||
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_rate_limit_skips_successful_responses() {
|
||||
let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60)));
|
||||
let app = Router::new()
|
||||
.route("/login", post(|| async { "ok" }))
|
||||
.layer(middleware::from_fn_with_state(limiter, auth_rate_limit_middleware));
|
||||
|
||||
// Successful responses (200) don't count toward the limit
|
||||
for _ in 0..5 {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_rate_limit_counts_failed_responses() {
|
||||
let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60)));
|
||||
let app = Router::new()
|
||||
.route("/login", post(|| async { StatusCode::UNAUTHORIZED }))
|
||||
.layer(middleware::from_fn_with_state(limiter, auth_rate_limit_middleware));
|
||||
|
||||
// First two failures pass through
|
||||
for _ in 0..2 {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Third request blocked by rate limiter
|
||||
let resp = app
|
||||
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_action_limit_uses_user_id_key() {
|
||||
let limiter = Arc::new(RateLimiter::new(1, Duration::from_secs(60)));
|
||||
|
||||
// Handler that injects a CurrentUser extension before the limiter
|
||||
let app = Router::new()
|
||||
.route("/action", post(|| async { "done" }))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
limiter.clone(),
|
||||
authenticated_action_rate_limit_middleware,
|
||||
))
|
||||
.layer(middleware::from_fn(
|
||||
|mut request: axum::extract::Request, next: axum::middleware::Next| async {
|
||||
request.extensions_mut().insert(CurrentUser {
|
||||
id: "user_42".into(),
|
||||
username: "admin".into(),
|
||||
});
|
||||
Ok::<_, std::convert::Infallible>(next.run(request).await)
|
||||
},
|
||||
));
|
||||
|
||||
// First request passes
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(Request::post("/action").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Second request for same user is rate limited
|
||||
let resp = app
|
||||
.oneshot(Request::post("/action").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// T12.3 — Cookie security attributes (via CookieConfig)
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn t12_3_session_cookie_is_httponly() {
|
||||
let config = CookieConfig {
|
||||
secure: false,
|
||||
same_site: "Lax",
|
||||
};
|
||||
let cookie = config.build_session_cookie("token123");
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
assert!(cookie.contains("Max-Age="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t12_3_session_cookie_secure_when_https() {
|
||||
let config = CookieConfig {
|
||||
secure: true,
|
||||
same_site: "Strict",
|
||||
};
|
||||
let cookie = config.build_session_cookie("token123");
|
||||
assert!(cookie.contains("; Secure"));
|
||||
assert!(cookie.contains("SameSite=Strict"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// T13 — Token extraction strategy
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn t13_1_authorization_header_takes_priority() {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(header::AUTHORIZATION, "Bearer header_tok".parse().unwrap());
|
||||
headers.insert(header::COOKIE, "nomifun-session=cookie_tok".parse().unwrap());
|
||||
assert_eq!(
|
||||
nomifun_auth::extract_token_from_headers(&headers),
|
||||
Some("header_tok".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t13_2_cookie_fallback() {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(header::COOKIE, "nomifun-session=fallback_tok".parse().unwrap());
|
||||
assert_eq!(
|
||||
nomifun_auth::extract_token_from_headers(&headers),
|
||||
Some("fallback_tok".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t13_3_no_token_returns_none() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
assert_eq!(nomifun_auth::extract_token_from_headers(&headers), None);
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
//! Black-box integration tests for auth REST API routes.
|
||||
//!
|
||||
//! Covers test-plan items T4 (login), T5 (logout), T6 (auth status),
|
||||
//! T7 (current user), T8 (change password), T9 (refresh token),
|
||||
//! T10 (ws token), T11 (QR login).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nomifun_auth::{
|
||||
AuthPolicy, AuthRouterState, CookieConfig, JwtService, QrTokenStore, TrustState, auth_routes, hash_password,
|
||||
trust_resolve_middleware,
|
||||
};
|
||||
use nomifun_db::{IUserRepository, SqliteUserRepository, init_database_memory};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a test app with an in-memory database.
|
||||
async fn test_app() -> (Router, TestContext) {
|
||||
test_app_with_local(false).await
|
||||
}
|
||||
|
||||
async fn test_app_with_local(local: bool) -> (Router, TestContext) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let user_repo = Arc::new(SqliteUserRepository::new(db.pool().clone())) as Arc<dyn IUserRepository>;
|
||||
let jwt_service = Arc::new(JwtService::new("test_secret_for_routes".into()));
|
||||
let cookie_config = Arc::new(CookieConfig {
|
||||
secure: false,
|
||||
same_site: "Lax",
|
||||
});
|
||||
let qr_token_store = Arc::new(QrTokenStore::new());
|
||||
|
||||
let state = AuthRouterState {
|
||||
jwt_service: jwt_service.clone(),
|
||||
user_repo: user_repo.clone(),
|
||||
cookie_config,
|
||||
qr_token_store: qr_token_store.clone(),
|
||||
};
|
||||
|
||||
// Mirror `create_router`: the global trust middleware resolves local trust
|
||||
// (and injects the system user / `LocalTrusted` marker) before the per-route
|
||||
// auth + local-only gates run. `local` maps to NoAuth (everything trusted),
|
||||
// otherwise Required (JWT enforced).
|
||||
let trust_state = TrustState {
|
||||
policy: if local { AuthPolicy::NoAuth } else { AuthPolicy::Required },
|
||||
local_trust_secret: None,
|
||||
};
|
||||
let app = auth_routes(state).layer(axum::middleware::from_fn_with_state(trust_state, trust_resolve_middleware));
|
||||
let ctx = TestContext {
|
||||
jwt_service,
|
||||
user_repo,
|
||||
qr_token_store,
|
||||
_db: db,
|
||||
};
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
/// Holds references needed by test assertions.
|
||||
struct TestContext {
|
||||
jwt_service: Arc<JwtService>,
|
||||
user_repo: Arc<dyn IUserRepository>,
|
||||
qr_token_store: Arc<QrTokenStore>,
|
||||
_db: nomifun_db::Database,
|
||||
}
|
||||
|
||||
/// Helper: create a test user with known credentials.
|
||||
///
|
||||
/// The seeded `system_default_user` row already uses `username = "admin"` with
|
||||
/// an empty password hash. If the test asks for that username, update the seed
|
||||
/// row in place instead of trying to INSERT a duplicate. Any other username
|
||||
/// takes the normal create_user path.
|
||||
async fn create_test_user(ctx: &TestContext, username: &str, password: &str) {
|
||||
let hash = hash_password(password).unwrap();
|
||||
if username == "admin" {
|
||||
ctx.user_repo
|
||||
.set_system_user_credentials(username, &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
ctx.user_repo.create_user(username, &hash).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: perform a JSON POST request.
|
||||
fn json_post(uri: &str, body: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper: perform a JSON POST request with auth token.
|
||||
fn json_post_with_token(uri: &str, body: &str, token: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper: perform a GET request with auth token.
|
||||
fn get_with_token(uri: &str, token: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper: perform a GET request without auth.
|
||||
fn get_anonymous(uri: &str) -> Request<Body> {
|
||||
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
/// Helper: extract response body as JSON.
|
||||
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// Helper: login and return (token, user_id).
|
||||
async fn login(app: &mut Router, username: &str, password: &str) -> (String, String) {
|
||||
let req = json_post(
|
||||
"/login",
|
||||
&format!(r#"{{"username":"{username}","password":"{password}"}}"#),
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
let token = json["token"].as_str().unwrap().to_owned();
|
||||
let user_id = json["user"]["id"].as_str().unwrap().to_owned();
|
||||
(token, user_id)
|
||||
}
|
||||
|
||||
fn json_post_anonymous(uri: &str, body: &str) -> Request<Body> {
|
||||
json_post(uri, body)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T4. Login (POST /login)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_1_login_success() {
|
||||
let (app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_post("/login", r#"{"username":"admin","password":"StrongP@ss1"}"#);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Check Set-Cookie header
|
||||
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
|
||||
assert!(set_cookie.contains("nomifun-session="));
|
||||
assert!(set_cookie.contains("HttpOnly"));
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["message"], "Login successful");
|
||||
assert!(json["token"].is_string());
|
||||
assert_eq!(json["user"]["username"], "admin");
|
||||
assert!(json["user"]["id"].is_string());
|
||||
|
||||
// Verify the returned token is valid
|
||||
let token = json["token"].as_str().unwrap();
|
||||
assert!(ctx.jwt_service.verify(token).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_2_login_nonexistent_user() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = json_post("/login", r#"{"username":"ghost","password":"whatever"}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_3_login_wrong_password() {
|
||||
let (app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "CorrectP@ss1").await;
|
||||
|
||||
let req = json_post("/login", r#"{"username":"admin","password":"WrongPass1"}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_4_login_missing_fields() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
// Missing password
|
||||
let req = json_post("/login", r#"{"username":"admin"}"#);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Missing username
|
||||
let req = json_post("/login", r#"{"password":"test"}"#);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Empty body
|
||||
let req = json_post("/login", r#"{}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_5_login_empty_password_hash_returns_401() {
|
||||
// Regression: when the seeded system user has an empty password_hash
|
||||
// (first-run local mode), POST /login must return 401, not 500.
|
||||
let (app, _ctx) = test_app_with_local(true).await;
|
||||
|
||||
let req = json_post("/login", r#"{"username":"admin","password":"anything"}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
assert_eq!(json["code"], "UNAUTHORIZED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_6_login_username_too_long() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let long_name = "a".repeat(33);
|
||||
let body = format!(r#"{{"username":"{long_name}","password":"test1234"}}"#);
|
||||
let req = json_post("/login", &body);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_6_login_password_too_long() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let long_pass = "a".repeat(129);
|
||||
let body = format!(r#"{{"username":"admin","password":"{long_pass}"}}"#);
|
||||
let req = json_post("/login", &body);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T5. Logout (POST /logout)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_1_logout_success() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = json_post_with_token("/logout", "", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Cookie should be cleared
|
||||
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
|
||||
assert!(set_cookie.contains("Max-Age=0"));
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["message"], "Logged out successfully");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_2_logout_token_becomes_invalid() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
|
||||
|
||||
// Logout
|
||||
let req = json_post_with_token("/logout", "", &token);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Try to use the token
|
||||
let req = get_with_token("/api/auth/user", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_3_logout_unauthenticated() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/logout")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T6. Auth Status (GET /api/auth/status)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_1_status_needs_setup() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = get_anonymous("/api/auth/status");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["needs_setup"], true);
|
||||
assert_eq!(json["is_authenticated"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_2_status_has_users() {
|
||||
let (app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_anonymous("/api/auth/status");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["needs_setup"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_3_status_authenticated() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/auth/status", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["is_authenticated"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_4_status_unauthenticated() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = get_anonymous("/api/auth/status");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["is_authenticated"], false);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T7. Current User (GET /api/auth/user)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_1_get_user_success() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/auth/user", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["user"]["username"], "admin");
|
||||
assert!(json["user"]["id"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_2_get_user_invalid_token() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = get_with_token("/api/auth/user", "invalid.jwt.token");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_3_get_user_no_token() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = get_anonymous("/api/auth/user");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T8. Change Password (POST /api/auth/change-password)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_change_password_success() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "OldP@ssword1").await;
|
||||
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
|
||||
|
||||
let req = json_post_with_token(
|
||||
"/api/auth/change-password",
|
||||
r#"{"current_password":"OldP@ssword1","new_password":"NewP@ssword2"}"#,
|
||||
&token,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["message"], "Password changed successfully");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2_change_password_old_token_invalidated() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "OldP@ssword1").await;
|
||||
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
|
||||
|
||||
// Change password
|
||||
let req = json_post_with_token(
|
||||
"/api/auth/change-password",
|
||||
r#"{"current_password":"OldP@ssword1","new_password":"NewP@ssword2"}"#,
|
||||
&token,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Old token should be invalid (JWT secret rotated)
|
||||
let req = get_with_token("/api/auth/user", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_3_change_password_wrong_current() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "CorrectP@ss1").await;
|
||||
let (token, _) = login(&mut app, "admin", "CorrectP@ss1").await;
|
||||
|
||||
let req = json_post_with_token(
|
||||
"/api/auth/change-password",
|
||||
r#"{"current_password":"WrongP@ss1","new_password":"NewP@ssword2"}"#,
|
||||
&token,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_4_change_password_new_too_short() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "OldP@ssword1").await;
|
||||
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
|
||||
|
||||
let req = json_post_with_token(
|
||||
"/api/auth/change-password",
|
||||
r#"{"current_password":"OldP@ssword1","new_password":"short"}"#,
|
||||
&token,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_6_change_password_weak() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "OldP@ssword1").await;
|
||||
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
|
||||
|
||||
let req = json_post_with_token(
|
||||
"/api/auth/change-password",
|
||||
r#"{"current_password":"OldP@ssword1","new_password":"password"}"#,
|
||||
&token,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_7_change_password_missing_fields() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "OldP@ssword1").await;
|
||||
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
|
||||
|
||||
// Missing newPassword
|
||||
let req = json_post_with_token(
|
||||
"/api/auth/change-password",
|
||||
r#"{"current_password":"OldP@ssword1"}"#,
|
||||
&token,
|
||||
);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Missing currentPassword
|
||||
let req = json_post_with_token(
|
||||
"/api/auth/change-password",
|
||||
r#"{"new_password":"NewP@ssword2"}"#,
|
||||
&token,
|
||||
);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T9. Refresh Token (POST /api/auth/refresh)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_1_refresh_token_success() {
|
||||
let (mut app, ctx) = test_app().await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
|
||||
|
||||
let body = format!(r#"{{"token":"{token}"}}"#);
|
||||
let req = json_post("/api/auth/refresh", &body);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert!(json["token"].is_string());
|
||||
|
||||
// New token should be valid
|
||||
let new_token = json["token"].as_str().unwrap();
|
||||
assert!(ctx.jwt_service.verify(new_token).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_2_refresh_invalid_token() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = json_post("/api/auth/refresh", r#"{"token":"fake.jwt.token"}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_3_refresh_missing_token() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = json_post("/api/auth/refresh", r#"{}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T10. WebSocket Token (GET /api/ws-token)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_1_ws_token_success() {
|
||||
let (mut app, _ctx) = test_app().await;
|
||||
create_test_user(&_ctx, "admin", "StrongP@ss1").await;
|
||||
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
|
||||
|
||||
let req = get_with_token("/api/ws-token", &token);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert!(json["ws_token"].is_string());
|
||||
assert!(json["expires_in"].is_number());
|
||||
|
||||
// expires_in should be 30 days in milliseconds
|
||||
let expires_in = json["expires_in"].as_u64().unwrap();
|
||||
assert_eq!(expires_in, 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_2_ws_token_unauthenticated() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = get_anonymous("/api/ws-token");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T11. QR Login (POST /api/auth/qr-login)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_1_qr_login_success() {
|
||||
let (app, ctx) = test_app().await;
|
||||
|
||||
// Set up system user with credentials so login works
|
||||
let hash = hash_password("syspass123").unwrap();
|
||||
ctx.user_repo
|
||||
.set_system_user_credentials("sysadmin", &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Generate QR token
|
||||
let qr_token = ctx.qr_token_store.generate();
|
||||
|
||||
let body = format!(r#"{{"qr_token":"{qr_token}"}}"#);
|
||||
let req = json_post("/api/auth/qr-login", &body);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Check Set-Cookie
|
||||
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
|
||||
assert!(set_cookie.contains("nomifun-session="));
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert!(json["token"].is_string());
|
||||
assert_eq!(json["user"]["username"], "sysadmin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_2_qr_login_invalid_token() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = json_post("/api/auth/qr-login", r#"{"qr_token":"nonexistent"}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_4_qr_login_already_used() {
|
||||
let (app, ctx) = test_app().await;
|
||||
|
||||
let hash = hash_password("syspass123").unwrap();
|
||||
ctx.user_repo
|
||||
.set_system_user_credentials("sysadmin", &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let qr_token = ctx.qr_token_store.generate();
|
||||
|
||||
// First use succeeds
|
||||
let body = format!(r#"{{"qr_token":"{qr_token}"}}"#);
|
||||
let req = json_post("/api/auth/qr-login", &body);
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// Second use fails
|
||||
let req = json_post("/api/auth/qr-login", &body);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_5_qr_login_missing_token() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = json_post("/api/auth/qr-login", r#"{}"#);
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// QR Login Page (GET /qr-login)
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn qr_login_page_returns_html() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let req = get_anonymous("/qr-login");
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let content_type = resp.headers().get("content-type").unwrap().to_str().unwrap();
|
||||
assert!(content_type.contains("text/html"));
|
||||
}
|
||||
|
||||
/// Regression: the served QR-login page must POST the snake_case field that
|
||||
/// `QrLoginRequest` deserializes (`qr_token`), not camelCase `qrToken`. A
|
||||
/// mismatch made every phone scan fail with the serde body-rejection
|
||||
/// "missing field `qr_token`".
|
||||
#[tokio::test]
|
||||
async fn qr_login_page_posts_snake_case_qr_token() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let resp = app.oneshot(get_anonymous("/qr-login")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let html = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
|
||||
assert!(
|
||||
html.contains("qr_token"),
|
||||
"QR-login page must POST the snake_case `qr_token` field"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("qrToken"),
|
||||
"QR-login page must not POST camelCase `qrToken` (serde rejects it)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn qr_login_page_hands_success_state_to_spa_before_redirecting() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let resp = app.oneshot(get_anonymous("/qr-login")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let html = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
|
||||
assert!(
|
||||
html.contains("credentials: 'same-origin'"),
|
||||
"QR-login POST must explicitly keep same-origin cookies"
|
||||
);
|
||||
assert!(
|
||||
html.contains("nomifun:qr-login-resume"),
|
||||
"QR-login page must stash the successful user for the SPA auth bridge"
|
||||
);
|
||||
assert!(
|
||||
html.contains("window.location.replace('/#/guid')"),
|
||||
"QR-login page must enter the conversation landing route explicitly after success"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn qr_login_page_checks_app_shell_before_redirecting() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let resp = app.oneshot(get_anonymous("/qr-login")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let html = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
|
||||
assert!(
|
||||
html.contains("verifyAppShellThenRedirect"),
|
||||
"QR-login page must verify the SPA shell before navigating away"
|
||||
);
|
||||
assert!(
|
||||
html.contains("nomifun_spa_shell_check=1"),
|
||||
"SPA shell probe should be identifiable in server/client diagnostics"
|
||||
);
|
||||
assert!(
|
||||
html.contains("WebUI app shell is not reachable"),
|
||||
"QR-login page should report app-shell HTTP failures instead of surfacing a browser error"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// T12. Local-only internal user routes
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_1_internal_user_routes_forbidden_outside_local_mode() {
|
||||
let (app, _ctx) = test_app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(get_anonymous("/api/auth/internal/users/system"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_internal_user_routes_work_in_local_mode() {
|
||||
let (app, ctx) = test_app_with_local(true).await;
|
||||
create_test_user(&ctx, "admin", "StrongP@ss1").await;
|
||||
|
||||
let system_resp = app
|
||||
.clone()
|
||||
.oneshot(get_anonymous("/api/auth/internal/users/system"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(system_resp.status(), StatusCode::OK);
|
||||
let system_json = body_json(system_resp).await;
|
||||
assert_eq!(system_json["data"]["id"], "system_default_user");
|
||||
|
||||
let user_resp = app
|
||||
.clone()
|
||||
.oneshot(get_anonymous("/api/auth/internal/users/by-username/admin"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(user_resp.status(), StatusCode::OK);
|
||||
let user_json = body_json(user_resp).await;
|
||||
let user_id = user_json["data"]["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let update_resp = app
|
||||
.clone()
|
||||
.oneshot(json_post_anonymous(
|
||||
&format!("/api/auth/internal/users/{user_id}/username"),
|
||||
r#"{"username":"renamed-admin"}"#,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(update_resp.status(), StatusCode::OK);
|
||||
|
||||
let renamed_resp = app
|
||||
.oneshot(get_anonymous("/api/auth/internal/users/by-username/renamed-admin"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(renamed_resp.status(), StatusCode::OK);
|
||||
let renamed_json = body_json(renamed_resp).await;
|
||||
assert_eq!(renamed_json["data"]["id"], user_id);
|
||||
assert_eq!(renamed_json["data"]["username"], "renamed-admin");
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Black-box validation tests (test-plan T15).
|
||||
//!
|
||||
//! Tests password and username validation rules as specified in API Spec 03-auth.md.
|
||||
|
||||
use nomifun_auth::{validate_password, validate_username};
|
||||
|
||||
// --- T15.1: Username legal ---
|
||||
|
||||
#[test]
|
||||
fn t15_1_valid_username_with_underscore_and_hyphen() {
|
||||
assert!(validate_username("test_user-1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_1_valid_username_alphanumeric() {
|
||||
assert!(validate_username("admin123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_1_valid_username_mixed_case() {
|
||||
assert!(validate_username("TestUser").is_ok());
|
||||
}
|
||||
|
||||
// --- T15.2: Username too short ---
|
||||
|
||||
#[test]
|
||||
fn t15_2_username_two_chars() {
|
||||
assert!(validate_username("ab").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_2_username_one_char() {
|
||||
assert!(validate_username("a").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_2_username_empty() {
|
||||
assert!(validate_username("").is_err());
|
||||
}
|
||||
|
||||
// --- T15.3: Username too long ---
|
||||
|
||||
#[test]
|
||||
fn t15_3_username_33_chars() {
|
||||
let name = "a".repeat(33);
|
||||
assert!(validate_username(&name).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_3_username_100_chars() {
|
||||
let name = "a".repeat(100);
|
||||
assert!(validate_username(&name).is_err());
|
||||
}
|
||||
|
||||
// --- T15.4: Username illegal characters ---
|
||||
|
||||
#[test]
|
||||
fn t15_4_username_with_at() {
|
||||
assert!(validate_username("test@user").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_4_username_with_space() {
|
||||
assert!(validate_username("test user").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_4_username_with_dot() {
|
||||
assert!(validate_username("test.user").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_4_username_with_slash() {
|
||||
assert!(validate_username("test/user").is_err());
|
||||
}
|
||||
|
||||
// --- T15.5: Username starts/ends with special chars ---
|
||||
|
||||
#[test]
|
||||
fn t15_5_starts_with_underscore() {
|
||||
assert!(validate_username("_test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_5_starts_with_hyphen() {
|
||||
assert!(validate_username("-test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_5_ends_with_underscore() {
|
||||
assert!(validate_username("test_").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t15_5_ends_with_hyphen() {
|
||||
assert!(validate_username("test-").is_err());
|
||||
}
|
||||
|
||||
// --- Password validation (supplement) ---
|
||||
|
||||
#[test]
|
||||
fn valid_password_accepted() {
|
||||
assert!(validate_password("StrongP@ss123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_too_short_rejected() {
|
||||
assert!(validate_password("short").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_too_long_rejected() {
|
||||
let long = "a".repeat(129);
|
||||
assert!(validate_password(&long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_password_rejected() {
|
||||
assert!(validate_password("password").is_err());
|
||||
assert!(validate_password("12345678").is_err());
|
||||
assert!(validate_password("qwertyui").is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user