Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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());
}