diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e55003..040bb05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,69 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.31 — 2026-06-19 + +**Spring Security parity — Tier 2: the web authentication mechanisms.** The +classic browser/login surface from Spring's `HttpSecurity`, built on the Tier 1 +authentication spine. All additive (no behaviour change to existing code). +Adversarially reviewed before release; the review's six confirmed findings are +fixed in this release. + +### Added + +- **HTTP Basic (`httpBasic()`)** — `HttpBasicLayer` reads + `Authorization: Basic …` and authenticates through the `AuthenticationManager` + spine. An **absent** header passes through (so a session/bearer layer can take + over); an **invalid or malformed** one is rejected with `401` and a + `WWW-Authenticate: Basic realm="…"` challenge (configurable realm, pluggable + `BasicAuthenticationEntryPoint`) — Spring's `BasicAuthenticationFilter`. +- **Form login (`formLogin()`)** — `form_login_routes` mounts `POST /login` + (url-encoded `username` + `password`), rotates the session id on success + (anti-fixation) **before** persisting the context through a + `SecurityContextRepository`, then redirects. Success/failure responses are + swappable (`FormLoginSuccessHandler` / `FormLoginFailureHandler`), and the + success path is saved-request-aware. +- **Remember-me (`rememberMe()`)** — `TokenBasedRememberMeServices` mints a + signed, expiring cookie token whose signature is an **HMAC-SHA256** keyed by a + server secret over the username, expiry, and the user's stored password hash: + a password change, an expired clock, a tampered token, or the wrong key all + reject. New trust-level methods on `Authentication` — + `is_remembered()` / `is_fully_authenticated()` (+ `REMEMBERED_CLAIM`) — so a + remembered context is authenticated but **not** fully authenticated (Spring's + `isFullyAuthenticated()`), and a sensitive route can demand a fresh login. +- **`RequestCache` / `SavedRequest`** — `HttpSessionRequestCache` remembers the + page an unauthenticated user wanted; form login returns them there after + login instead of the default target (Spring's + `SavedRequestAwareAuthenticationSuccessHandler`). Only **same-origin** targets + are honoured (`SavedRequest::is_safe_redirect`): a protocol-relative, + backslash-tricked, absolute, or control-char target falls back to the + configured success URL, so the login flow can't be turned into an open + redirect. `NullRequestCache` for stateless surfaces. +- **`SessionCreationPolicy`** — `Always` / `IfRequired` (default) / `Never` / + `Stateless` (Spring's `sessionManagement().sessionCreationPolicy(...)`). + `SessionAuthenticationLayer::session_creation_policy(...)` installs the implied + `SecurityContextRepository`; `Stateless` uses the null repository (no session + context) for token-only APIs. +- **Multiple filter chains** — `SecurityFilterChains` routes each request to the + first chain whose `RequestMatcher` (`AnyRequestMatcher` / + `PathRequestMatcher`, segment-aware, optional method) matches, so a + locked-down `/api/**` and a permissive web surface coexist (Spring's + `FilterChainProxy`); an unmatched request passes through. The dispatcher + honours tower's readiness contract for a backpressure-bearing inner service. + +### Known limitations (roadmap) + +- `TokenBasedRememberMeServices` is the **stateless** of Spring's two + remember-me strategies: a captured cookie replays for the full validity window + (default 14 days) until the embedded expiry passes or the user's password hash + changes — there is no per-token series/rotation theft detection (Spring's + `PersistentTokenBasedRememberMeServices`) and no server-side revocation list. + Use a short `token_validity_seconds` and serve the cookie `HttpOnly` + `Secure` + + `SameSite`. A persistent/series variant is a follow-up. +- `RequestCache::save_request` is provided for an authentication entry point to + call before redirecting to login; wiring an entry point that auto-saves the + request is left to the application (the consume side is wired into form login). + ## v26.6.30 — 2026-06-19 **Spring Security parity — Tier 1: the authentication spine.** The core of diff --git a/Cargo.lock b/Cargo.lock index 473aa51..9a5b4f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,7 +1495,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "firefly-actuator", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1585,7 +1585,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "inventory", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-observability", @@ -1626,7 +1626,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-cache", @@ -1650,7 +1650,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "chrono", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-stream", "axum", @@ -1719,7 +1719,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "regex", @@ -1734,7 +1734,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.30" +version = "26.6.31" dependencies = [ "futures", "inventory", @@ -1761,7 +1761,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -1784,7 +1784,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-stream", "async-trait", @@ -1801,7 +1801,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-stream", "async-trait", @@ -1819,7 +1819,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-stream", "async-trait", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -1857,7 +1857,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1874,7 +1874,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1891,7 +1891,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1908,7 +1908,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1928,7 +1928,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "base64 0.22.1", @@ -1970,7 +1970,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-eda", @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -2003,7 +2003,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-eda", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-eda", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "base64 0.22.1", @@ -2052,7 +2052,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "http", @@ -2067,7 +2067,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2083,7 +2083,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2121,7 +2121,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2143,7 +2143,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2160,7 +2160,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2195,7 +2195,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.30" +version = "26.6.31" dependencies = [ "chrono", "serde", @@ -2207,7 +2207,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.30" +version = "26.6.31" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.30" +version = "26.6.31" dependencies = [ "chrono", "hex", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -2268,7 +2268,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2284,7 +2284,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2301,7 +2301,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2318,7 +2318,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "base64 0.22.1", @@ -2335,7 +2335,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2351,7 +2351,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -2375,7 +2375,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "chrono", @@ -2391,7 +2391,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2415,7 +2415,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -2425,7 +2425,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-stream", "firefly-kernel", @@ -2437,7 +2437,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-config", @@ -2449,7 +2449,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2469,7 +2469,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2486,7 +2486,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -2501,7 +2501,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.30" +version = "26.6.31" dependencies = [ "chrono", "firefly", @@ -2512,7 +2512,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.30" +version = "26.6.31" dependencies = [ "chrono", "firefly", @@ -2525,7 +2525,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.30" +version = "26.6.31" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2537,7 +2537,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "firefly", @@ -2555,7 +2555,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "firefly", @@ -2568,7 +2568,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2591,7 +2591,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-stream", "async-trait", @@ -2631,7 +2631,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "chrono", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.30" +version = "26.6.31" dependencies = [ "argon2", "async-trait", @@ -2661,6 +2661,7 @@ dependencies = [ "bcrypt", "firefly-session", "globset", + "hmac 0.12.1", "http", "http-body-util", "jsonwebtoken", @@ -2682,7 +2683,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2707,7 +2708,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-session", @@ -2720,7 +2721,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-session", @@ -2732,7 +2733,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-session", @@ -2744,7 +2745,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "futures", @@ -2754,7 +2755,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "firefly", @@ -2762,7 +2763,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "firefly", @@ -2774,7 +2775,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "bytes", @@ -2790,7 +2791,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-cqrs", @@ -2804,7 +2805,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2830,7 +2831,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "firefly-cqrs", @@ -2843,7 +2844,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2855,7 +2856,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2876,7 +2877,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "firefly-kernel", @@ -2891,7 +2892,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.30" +version = "26.6.31" dependencies = [ "axum", "base64 0.22.1", @@ -2910,7 +2911,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "inventory", @@ -2921,7 +2922,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.30" +version = "26.6.31" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -2937,7 +2938,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.30" +version = "26.6.31" dependencies = [ "chrono", "firefly-kernel", @@ -2948,7 +2949,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -2987,7 +2988,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", @@ -3015,7 +3016,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.30" +version = "26.6.31" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index c2e6257..b51b66d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.30" +version = "26.6.31" edition = "2021" license = "Apache-2.0" repository = "https://github.com/fireflyframework/fireflyframework-rust" @@ -99,80 +99,80 @@ rust-version = "1.88" [workspace.dependencies] # ---- internal crates ---- -firefly-reactive = { path = "crates/reactive", version = "26.6.30" } -firefly-kernel = { path = "crates/kernel", version = "26.6.30" } -firefly-utils = { path = "crates/utils", version = "26.6.30" } -firefly-validators = { path = "crates/validators", version = "26.6.30" } -firefly-web = { path = "crates/web", version = "26.6.30" } -firefly-config = { path = "crates/config", version = "26.6.30" } -firefly-i18n = { path = "crates/i18n", version = "26.6.30" } -firefly-cache = { path = "crates/cache", version = "26.6.30" } -firefly-observability = { path = "crates/observability", version = "26.6.30" } -firefly-data = { path = "crates/data", version = "26.6.30" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.30" } -firefly-eda = { path = "crates/eda", version = "26.6.30" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.30" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.30" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.30" } -firefly-plugins = { path = "crates/plugins", version = "26.6.30" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.30" } -firefly-actuator = { path = "crates/actuator", version = "26.6.30" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.30" } -firefly-resilience = { path = "crates/resilience", version = "26.6.30" } -firefly-security = { path = "crates/security", version = "26.6.30" } -firefly-migrations = { path = "crates/migrations", version = "26.6.30" } -firefly-openapi = { path = "crates/openapi", version = "26.6.30" } -firefly-sse = { path = "crates/sse", version = "26.6.30" } -firefly-transactional = { path = "crates/transactional", version = "26.6.30" } -firefly-testkit = { path = "crates/testkit", version = "26.6.30" } -firefly-client = { path = "crates/client", version = "26.6.30" } -firefly-config-server = { path = "crates/config-server", version = "26.6.30" } -firefly-idp = { path = "crates/idp", version = "26.6.30" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.30" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.30" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.30" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.30" } -firefly-ecm = { path = "crates/ecm", version = "26.6.30" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.30" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.30" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.30" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.30" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.30" } -firefly-notifications = { path = "crates/notifications", version = "26.6.30" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.30" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.30" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.30" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.30" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.30" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.30" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.30" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.30" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.30" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.30" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.30" } -firefly-admin = { path = "crates/admin", version = "26.6.30" } -firefly-aop = { path = "crates/aop", version = "26.6.30" } -firefly-cli = { path = "crates/cli", version = "26.6.30" } -firefly-container = { path = "crates/container", version = "26.6.30" } -firefly-session = { path = "crates/session", version = "26.6.30" } -firefly-shell = { path = "crates/shell", version = "26.6.30" } -firefly-websocket = { path = "crates/websocket", version = "26.6.30" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.30" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.30" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.30" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.30" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.30" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.30" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.30" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.30" } -firefly = { path = "crates/firefly", version = "26.6.30" } -firefly-macros = { path = "crates/macros", version = "26.6.30" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.30" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.30" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.30" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.30" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.30" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.30" } +firefly-reactive = { path = "crates/reactive", version = "26.6.31" } +firefly-kernel = { path = "crates/kernel", version = "26.6.31" } +firefly-utils = { path = "crates/utils", version = "26.6.31" } +firefly-validators = { path = "crates/validators", version = "26.6.31" } +firefly-web = { path = "crates/web", version = "26.6.31" } +firefly-config = { path = "crates/config", version = "26.6.31" } +firefly-i18n = { path = "crates/i18n", version = "26.6.31" } +firefly-cache = { path = "crates/cache", version = "26.6.31" } +firefly-observability = { path = "crates/observability", version = "26.6.31" } +firefly-data = { path = "crates/data", version = "26.6.31" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.31" } +firefly-eda = { path = "crates/eda", version = "26.6.31" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.31" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.31" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.31" } +firefly-plugins = { path = "crates/plugins", version = "26.6.31" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.31" } +firefly-actuator = { path = "crates/actuator", version = "26.6.31" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.31" } +firefly-resilience = { path = "crates/resilience", version = "26.6.31" } +firefly-security = { path = "crates/security", version = "26.6.31" } +firefly-migrations = { path = "crates/migrations", version = "26.6.31" } +firefly-openapi = { path = "crates/openapi", version = "26.6.31" } +firefly-sse = { path = "crates/sse", version = "26.6.31" } +firefly-transactional = { path = "crates/transactional", version = "26.6.31" } +firefly-testkit = { path = "crates/testkit", version = "26.6.31" } +firefly-client = { path = "crates/client", version = "26.6.31" } +firefly-config-server = { path = "crates/config-server", version = "26.6.31" } +firefly-idp = { path = "crates/idp", version = "26.6.31" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.31" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.31" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.31" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.31" } +firefly-ecm = { path = "crates/ecm", version = "26.6.31" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.31" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.31" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.31" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.31" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.31" } +firefly-notifications = { path = "crates/notifications", version = "26.6.31" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.31" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.31" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.31" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.31" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.31" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.31" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.31" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.31" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.31" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.31" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.31" } +firefly-admin = { path = "crates/admin", version = "26.6.31" } +firefly-aop = { path = "crates/aop", version = "26.6.31" } +firefly-cli = { path = "crates/cli", version = "26.6.31" } +firefly-container = { path = "crates/container", version = "26.6.31" } +firefly-session = { path = "crates/session", version = "26.6.31" } +firefly-shell = { path = "crates/shell", version = "26.6.31" } +firefly-websocket = { path = "crates/websocket", version = "26.6.31" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.31" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.31" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.31" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.31" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.31" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.31" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.31" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.31" } +firefly = { path = "crates/firefly", version = "26.6.31" } +firefly-macros = { path = "crates/macros", version = "26.6.31" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.31" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.31" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.31" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.31" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.31" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.31" } # ---- async runtime + web ---- tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal", "io-util", "net", "fs"] } diff --git a/MODULES.md b/MODULES.md index cdede0f..d2303f6 100644 --- a/MODULES.md +++ b/MODULES.md @@ -48,7 +48,7 @@ declarative macros instead of hand-rolled builder wiring. | [`firefly-actuator`](crates/actuator/README.md) | `/actuator/{health,info,metrics,env,tasks,version}` + liveness/readiness probes, runtime loggers, `httpexchanges`, `threaddump`, labeled Micrometer metrics, `refresh`, `management.endpoints.web` exposure | | [`firefly-scheduling`](crates/scheduling/README.md) | Cron + FixedRate + FixedDelay `Scheduler` | | [`firefly-resilience`](crates/resilience/README.md) | `CircuitBreaker`, `RateLimiter`, `Bulkhead`, `Timeout`, composable `Chain` | -| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + authorization server), **one-time-token + WebAuthn/passkey** passwordless login, `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt + Argon2id) — Spring Security 6-faithful (see the book's *Spring Security Parity* appendix) | +| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), the authentication spine (`AuthenticationManager`/`ProviderManager`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `DelegatingPasswordEncoder`), web mechanisms (`httpBasic`, `formLogin`, `TokenBasedRememberMeServices`, `RequestCache`, `SessionCreationPolicy`, `SecurityFilterChains`), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + authorization server), **one-time-token + WebAuthn/passkey** passwordless login, `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt + Argon2id) — Spring Security 6-faithful (see the book's *Spring Security Parity* appendix) | | [`firefly-migrations`](crates/migrations/README.md) | Versioned SQL migrations (`V001__init.sql`) over a `Database` port | | [`firefly-openapi`](crates/openapi/README.md) | OpenAPI 3.1 generator + Swagger-UI shim | | [`firefly-sse`](crates/sse/README.md) | Server-Sent Events writer w/ heartbeat + Last-Event-Id | diff --git a/crates/security/Cargo.toml b/crates/security/Cargo.toml index a5e1071..b08977d 100644 --- a/crates/security/Cargo.toml +++ b/crates/security/Cargo.toml @@ -30,6 +30,7 @@ globset = { workspace = true } rand = { workspace = true } base64 = { workspace = true } sha2 = { workspace = true } +hmac = { workspace = true } redis = { workspace = true } tokio-postgres = { workspace = true } bcrypt = { workspace = true } diff --git a/crates/security/src/authentication.rs b/crates/security/src/authentication.rs index 6009342..c6ac984 100644 --- a/crates/security/src/authentication.rs +++ b/crates/security/src/authentication.rs @@ -95,6 +95,25 @@ impl Authentication { pub fn is_authenticated(&self) -> bool { !self.principal.is_empty() && self.principal != ANONYMOUS_ID } + + /// Whether this context was established by a *remember-me* token rather than + /// a fresh credential presentation (marked by the [`REMEMBERED_CLAIM`]). + /// The Rust analog of a `RememberMeAuthenticationToken`. + #[must_use] + pub fn is_remembered(&self) -> bool { + self.claims + .get(REMEMBERED_CLAIM) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + } + + /// Whether this is a *fully* authenticated principal — authenticated by a + /// fresh credential, not anonymous and not remember-me — Spring's + /// `isFullyAuthenticated()`. Sensitive operations can require this. + #[must_use] + pub fn is_fully_authenticated(&self) -> bool { + self.is_authenticated() && !self.is_remembered() + } } /// `ANONYMOUS_ID` is the principal id used when no auth is present and @@ -107,6 +126,10 @@ pub const ANONYMOUS_ID: &str = "anonymous"; /// `ROLE_X` (and, for backward compatibility, a bare `X`). pub const ROLE_PREFIX: &str = "ROLE_"; +/// Claim key marking an [`Authentication`] as established by a remember-me +/// token (a boolean `true`); see [`Authentication::is_remembered`]. +pub const REMEMBERED_CLAIM: &str = "firefly:remembered"; + /// `SecurityError` is the typed error family of the security tier. /// /// The `Display` strings match the Go port's sentinel errors exactly, diff --git a/crates/security/src/exception.rs b/crates/security/src/exception.rs index 8159293..0fdf172 100644 --- a/crates/security/src/exception.rs +++ b/crates/security/src/exception.rs @@ -25,6 +25,7 @@ use axum::extract::Request; use axum::response::Response; +use http::header; use crate::problem; @@ -63,3 +64,51 @@ impl AccessDeniedHandler for ProblemAccessDeniedHandler { problem::forbidden(detail) } } + +/// An [`AuthenticationEntryPoint`] that issues an HTTP Basic challenge — the +/// Rust analog of Spring's `BasicAuthenticationEntryPoint`. Renders the +/// canonical `401` plus `WWW-Authenticate: Basic realm="", +/// charset="UTF-8"`, prompting the browser/client for credentials. +#[derive(Debug, Clone)] +pub struct BasicAuthenticationEntryPoint { + realm: String, +} + +impl BasicAuthenticationEntryPoint { + /// Builds the entry point for `realm`. + #[must_use] + pub fn new(realm: impl Into) -> Self { + Self { + realm: realm.into(), + } + } +} + +impl Default for BasicAuthenticationEntryPoint { + fn default() -> Self { + Self::new("Realm") + } +} + +impl AuthenticationEntryPoint for BasicAuthenticationEntryPoint { + fn commence(&self, _request: &Request, detail: &str) -> Response { + let mut response = problem::unauthorized(detail); + let realm = sanitize_realm(&self.realm); + let challenge = format!("Basic realm=\"{realm}\", charset=\"UTF-8\""); + if let Ok(value) = http::HeaderValue::from_str(&challenge) { + response + .headers_mut() + .insert(header::WWW_AUTHENTICATE, value); + } + response + } +} + +/// Strips characters that would break (or inject into) the `WWW-Authenticate` +/// quoted-string `realm`. +fn sanitize_realm(realm: &str) -> String { + realm + .chars() + .filter(|c| *c != '"' && *c != '\\' && !c.is_control()) + .collect() +} diff --git a/crates/security/src/filter_chain.rs b/crates/security/src/filter_chain.rs index e2fa1b5..6d1cf1c 100644 --- a/crates/security/src/filter_chain.rs +++ b/crates/security/src/filter_chain.rs @@ -105,7 +105,7 @@ impl CompiledRule { /// ends at a path-segment boundary, so `/api` matches `/api` and `/api/...` /// but **not** `/api-internal` or `/apixyz` (where a raw `starts_with` leaks). /// An empty prefix matches every path (Go parity for the `""` prefix). -fn prefix_matches(path: &str, prefix: &str) -> bool { +pub(crate) fn prefix_matches(path: &str, prefix: &str) -> bool { if prefix.is_empty() { return true; } diff --git a/crates/security/src/form_login.rs b/crates/security/src/form_login.rs new file mode 100644 index 0000000..d5c44b0 --- /dev/null +++ b/crates/security/src/form_login.rs @@ -0,0 +1,377 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Form login — the Rust analog of Spring Security's `formLogin()` +//! (`UsernamePasswordAuthenticationFilter`). +//! +//! [`form_login_routes`] mounts a `POST /login` endpoint that takes a +//! url-encoded `username` + `password`, authenticates them through the Tier 1 +//! [`AuthenticationManager`](crate::AuthenticationManager) spine, and — on +//! success — rotates the session id (anti-fixation), persists the +//! [`Authentication`](crate::Authentication) through a +//! [`SecurityContextRepository`](crate::SecurityContextRepository) (so +//! [`SessionAuthenticationLayer`](crate::SessionAuthenticationLayer) restores it +//! on later requests), and redirects to the success URL. A failed login +//! redirects to the failure URL. Both URLs are configurable, and the success/ +//! failure rendering can be swapped via [`FormLoginSuccessHandler`] / +//! [`FormLoginFailureHandler`]. + +use std::sync::Arc; + +use async_trait::async_trait; +use axum::extract::{Form, State}; +use axum::response::Response; +use axum::{Extension, Router}; +use http::{header, StatusCode}; +use serde::Deserialize; + +use firefly_session::Session; + +use crate::authentication::{Authentication, SecurityError}; +use crate::authentication_manager::{AuthenticationManager, AuthenticationRequest}; +use crate::request_cache::{HttpSessionRequestCache, RequestCache}; +use crate::security_context::{HttpSessionSecurityContextRepository, SecurityContextRepository}; + +/// Renders the response after a **successful** form login — Spring's +/// `AuthenticationSuccessHandler`. +#[async_trait] +pub trait FormLoginSuccessHandler: Send + Sync { + /// Builds the success response (default: a 302 redirect). + async fn on_success(&self, auth: &Authentication) -> Response; +} + +/// Renders the response after a **failed** form login — Spring's +/// `AuthenticationFailureHandler`. +#[async_trait] +pub trait FormLoginFailureHandler: Send + Sync { + /// Builds the failure response (default: a 302 redirect). + async fn on_failure(&self, error: &SecurityError) -> Response; +} + +/// Default success handler: 302 redirect to a fixed URL. +struct RedirectSuccess(String); +#[async_trait] +impl FormLoginSuccessHandler for RedirectSuccess { + async fn on_success(&self, _auth: &Authentication) -> Response { + redirect(&self.0) + } +} + +/// Default failure handler: 302 redirect to a fixed URL. +struct RedirectFailure(String); +#[async_trait] +impl FormLoginFailureHandler for RedirectFailure { + async fn on_failure(&self, _error: &SecurityError) -> Response { + redirect(&self.0) + } +} + +/// Shared state for the form-login route. +pub struct FormLoginState { + manager: Arc, + repository: Arc, + request_cache: Arc, + success: Arc, + failure: Arc, +} + +impl FormLoginState { + /// Builds the state over the Tier 1 [`AuthenticationManager`], persisting + /// the context with the default + /// [`HttpSessionSecurityContextRepository`], redirecting to `"/"` on + /// success and `"/login?error"` on failure (Spring's defaults). On success + /// it prefers any pre-login [`SavedRequest`](crate::SavedRequest) held by + /// the default [`HttpSessionRequestCache`] — Spring's + /// `SavedRequestAwareAuthenticationSuccessHandler`. + #[must_use] + pub fn new(manager: Arc) -> Self { + Self { + manager, + repository: Arc::new(HttpSessionSecurityContextRepository::new()), + request_cache: Arc::new(HttpSessionRequestCache::new()), + success: Arc::new(RedirectSuccess("/".to_string())), + failure: Arc::new(RedirectFailure("/login?error".to_string())), + } + } + + /// Sets the post-login success redirect target. + #[must_use] + pub fn success_url(mut self, url: impl Into) -> Self { + self.success = Arc::new(RedirectSuccess(url.into())); + self + } + + /// Sets the failure redirect target. + #[must_use] + pub fn failure_url(mut self, url: impl Into) -> Self { + self.failure = Arc::new(RedirectFailure(url.into())); + self + } + + /// Overrides the [`SecurityContextRepository`] used to persist the context. + #[must_use] + pub fn repository(mut self, repository: Arc) -> Self { + self.repository = repository; + self + } + + /// Overrides the [`RequestCache`] consulted for a pre-login + /// [`SavedRequest`](crate::SavedRequest). Set a + /// [`NullRequestCache`](crate::NullRequestCache) to always use the + /// configured success target. + #[must_use] + pub fn request_cache(mut self, request_cache: Arc) -> Self { + self.request_cache = request_cache; + self + } + + /// Overrides the success handler. + #[must_use] + pub fn success_handler(mut self, handler: Arc) -> Self { + self.success = handler; + self + } + + /// Overrides the failure handler. + #[must_use] + pub fn failure_handler(mut self, handler: Arc) -> Self { + self.failure = handler; + self + } +} + +#[derive(Deserialize)] +struct LoginForm { + username: String, + password: String, +} + +/// `POST /login` — authenticate username/password, establish the session +/// security context (rotating the id), and hand off to the success/failure +/// handler. +async fn handle_login( + State(state): State>, + Extension(session): Extension, + Form(form): Form, +) -> Response { + match state + .manager + .authenticate(AuthenticationRequest::username_password( + form.username, + form.password, + )) + .await + { + Ok(auth) => { + // Anti-fixation: rotate the session id on authentication, then + // persist the context where SessionAuthenticationLayer restores it. + session.rotate_id().await; + state.repository.save(&session, &auth).await; + // Saved-request-aware: prefer the page the user originally wanted + // (cached by the entry point) over the configured success target — + // but only for a safe same-origin target, so a crafted off-site + // saved path can never turn login into an open redirect. + if let Some(saved) = state.request_cache.get_request(&session).await { + state.request_cache.remove_request(&session).await; + if saved.is_safe_redirect() { + return redirect(saved.redirect_url()); + } + } + state.success.on_success(&auth).await + } + Err(error) => state.failure.on_failure(&error).await, + } +} + +/// Builds the form-login route (`POST /login`). Mount behind a +/// [`firefly_session::SessionLayer`]; pair with a +/// [`SessionAuthenticationLayer`](crate::SessionAuthenticationLayer) to restore +/// the established context on subsequent requests. +pub fn form_login_routes(state: Arc) -> Router { + Router::new() + .route("/login", axum::routing::post(handle_login)) + .with_state(state) +} + +/// 302 redirect to `location`. The `Location` header value is built fallibly — +/// a `location` carrying a control character (CR/LF, …) would otherwise make +/// the response builder fail; rather than panic, fall back to `"/"` (so a +/// crafted or misconfigured target degrades safely instead of crashing the +/// request, and cannot split the response header). +fn redirect(location: &str) -> Response { + let value = header::HeaderValue::from_str(location) + .unwrap_or_else(|_| header::HeaderValue::from_static("/")); + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, value) + .body(axum::body::Body::empty()) + .expect("redirect response with a valid header value must build") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authentication_manager::ProviderManager; + use crate::oauth2::SESSION_KEY_SECURITY_CONTEXT; + use crate::password::{BcryptPasswordEncoder, PasswordEncoder}; + use crate::userdetails::{DaoAuthenticationProvider, InMemoryUserDetailsService, UserDetails}; + use firefly_session::SessionInner; + use tower::ServiceExt; + + fn manager() -> Arc { + let hash = BcryptPasswordEncoder::with_rounds(4).hash("pw").unwrap(); + let uds = Arc::new( + InMemoryUserDetailsService::new().with_user(UserDetails::new( + "alice", + hash, + vec!["USER".into()], + )), + ); + let provider = Arc::new(DaoAuthenticationProvider::new( + uds, + Arc::new(BcryptPasswordEncoder::with_rounds(4)), + )); + Arc::new(ProviderManager::new(vec![provider])) + } + + async fn post_login(body: &str) -> (Response, Session) { + let state = Arc::new(FormLoginState::new(manager()).success_url("/home")); + let app = form_login_routes(state); + let session = Session::new(SessionInner::new("sid")); + let mut req = http::Request::builder() + .method(http::Method::POST) + .uri("/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(axum::body::Body::from(body.to_owned())) + .unwrap(); + req.extensions_mut().insert(session.clone()); + let resp = app.oneshot(req).await.unwrap(); + (resp, session) + } + + #[tokio::test] + async fn valid_login_sets_context_and_rotates_session() { + let session_id_before; + let (resp, session) = { + // Build the session first so we can read its id before/after. + let state = Arc::new(FormLoginState::new(manager()).success_url("/home")); + let app = form_login_routes(state); + let session = Session::new(SessionInner::new("sid")); + session_id_before = session.id().await; + let mut req = http::Request::builder() + .method(http::Method::POST) + .uri("/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(axum::body::Body::from("username=alice&password=pw")) + .unwrap(); + req.extensions_mut().insert(session.clone()); + (app.oneshot(req).await.unwrap(), session) + }; + + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.headers()[header::LOCATION], "/home"); + // Session id rotated (anti-fixation). + assert_ne!(session.id().await, session_id_before); + // Security context persisted. + let ctx = session + .attribute::(SESSION_KEY_SECURITY_CONTEXT) + .await + .expect("context stored"); + let auth: Authentication = serde_json::from_str(&ctx).unwrap(); + assert_eq!(auth.principal, "alice"); + } + + #[tokio::test] + async fn invalid_login_redirects_to_failure_without_context() { + let (resp, session) = post_login("username=alice&password=wrong").await; + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.headers()[header::LOCATION], "/login?error"); + assert!(session + .attribute::(SESSION_KEY_SECURITY_CONTEXT) + .await + .is_none()); + } + + #[tokio::test] + async fn valid_login_returns_to_the_saved_request() { + use crate::request_cache::{HttpSessionRequestCache, SavedRequest}; + + let state = Arc::new(FormLoginState::new(manager()).success_url("/home")); + let app = form_login_routes(state); + let session = Session::new(SessionInner::new("sid")); + // The entry point cached the page the user originally asked for. + let cache = HttpSessionRequestCache::new(); + cache + .save_request(&session, SavedRequest::new("GET", "/reports?y=2026")) + .await; + + let mut req = http::Request::builder() + .method(http::Method::POST) + .uri("/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(axum::body::Body::from("username=alice&password=pw")) + .unwrap(); + req.extensions_mut().insert(session.clone()); + let resp = app.oneshot(req).await.unwrap(); + + // Redirected to the saved page, not the default success URL... + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.headers()[header::LOCATION], "/reports?y=2026"); + // ...and the saved request was consumed. + assert!(cache.get_request(&session).await.is_none()); + } + + #[tokio::test] + async fn unsafe_saved_request_does_not_open_redirect() { + use crate::request_cache::{HttpSessionRequestCache, SavedRequest}; + + let state = Arc::new(FormLoginState::new(manager()).success_url("/home")); + let app = form_login_routes(state); + let session = Session::new(SessionInner::new("sid")); + // A crafted protocol-relative path that would redirect off-site. + let cache = HttpSessionRequestCache::new(); + cache + .save_request(&session, SavedRequest::new("GET", "//evil.com")) + .await; + + let mut req = http::Request::builder() + .method(http::Method::POST) + .uri("/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(axum::body::Body::from("username=alice&password=pw")) + .unwrap(); + req.extensions_mut().insert(session.clone()); + let resp = app.oneshot(req).await.unwrap(); + + // Falls back to the configured (same-origin) success URL, never //evil.com... + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.headers()[header::LOCATION], "/home"); + // ...and the poisoned saved request is still consumed. + assert!(cache.get_request(&session).await.is_none()); + } + + #[test] + fn redirect_does_not_panic_on_a_control_char_location() { + // A control char would make HeaderValue construction fail; redirect() + // must fall back to "/" rather than panic the request thread. + let resp = redirect("/x\r\nSet-Cookie: evil=1"); + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.headers()[header::LOCATION], "/"); + // A normal target is untouched. + assert_eq!( + redirect("/dashboard").headers()[header::LOCATION], + "/dashboard" + ); + } +} diff --git a/crates/security/src/http_basic.rs b/crates/security/src/http_basic.rs new file mode 100644 index 0000000..e060f95 --- /dev/null +++ b/crates/security/src/http_basic.rs @@ -0,0 +1,272 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! HTTP Basic authentication — the Rust analog of Spring Security's +//! `httpBasic()` (`BasicAuthenticationFilter`). +//! +//! [`HttpBasicLayer`] reads `Authorization: Basic `, +//! authenticates the credentials through the Tier 1 +//! [`AuthenticationManager`](crate::AuthenticationManager) spine, and — on +//! success — stores the resulting [`Authentication`](crate::Authentication) on +//! the request extensions and scopes it as the ambient context (so +//! `#[pre_authorize]` / `FilterChain` / handlers see it), exactly as +//! [`BearerLayer`](crate::BearerLayer) does. +//! +//! * A **present, valid** header authenticates. +//! * A **present, invalid/malformed** header is rejected with `401` + +//! `WWW-Authenticate: Basic` (via a +//! [`BasicAuthenticationEntryPoint`](crate::BasicAuthenticationEntryPoint)). +//! * An **absent** header passes through untouched (Spring's +//! `BasicAuthenticationFilter` behaviour) — a following session/bearer layer +//! or the `FilterChain`'s deny-by-default then governs the request. + +use std::convert::Infallible; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::extract::Request; +use axum::response::Response; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use http::header::AUTHORIZATION; +use http::HeaderMap; +use tower::{Layer, Service}; + +use crate::authentication_manager::{AuthenticationManager, AuthenticationRequest}; +use crate::exception::{AuthenticationEntryPoint, BasicAuthenticationEntryPoint}; + +/// Tower [`Layer`] applying HTTP Basic authentication — Spring's `httpBasic()`. +#[derive(Clone)] +pub struct HttpBasicLayer { + manager: Arc, + entry_point: Arc, +} + +impl HttpBasicLayer { + /// Builds the layer over an [`AuthenticationManager`] (the Tier 1 spine), + /// challenging with the default realm `"Realm"`. + #[must_use] + pub fn new(manager: Arc) -> Self { + Self { + manager, + entry_point: Arc::new(BasicAuthenticationEntryPoint::default()), + } + } + + /// Sets the Basic `realm` advertised in the `WWW-Authenticate` challenge. + #[must_use] + pub fn realm(mut self, realm: impl Into) -> Self { + self.entry_point = Arc::new(BasicAuthenticationEntryPoint::new(realm)); + self + } + + /// Overrides the [`AuthenticationEntryPoint`] used to render the `401`. + #[must_use] + pub fn entry_point(mut self, entry_point: Arc) -> Self { + self.entry_point = entry_point; + self + } +} + +impl std::fmt::Debug for HttpBasicLayer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpBasicLayer").finish_non_exhaustive() + } +} + +impl Layer for HttpBasicLayer { + type Service = HttpBasicService; + + fn layer(&self, inner: S) -> Self::Service { + HttpBasicService { + inner, + manager: Arc::clone(&self.manager), + entry_point: Arc::clone(&self.entry_point), + } + } +} + +/// The tower service produced by [`HttpBasicLayer`]. +#[derive(Clone)] +pub struct HttpBasicService { + inner: S, + manager: Arc, + entry_point: Arc, +} + +impl Service for HttpBasicService +where + S: Service + Clone + Send + 'static, + S::Future: Send + 'static, +{ + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); + let manager = Arc::clone(&self.manager); + let entry_point = Arc::clone(&self.entry_point); + let creds = parse_basic(req.headers()); + + Box::pin(async move { + match creds { + // No `Basic` header — pass through (Spring continues the chain). + None => inner.call(req).await, + // Present but undecodable / no colon — reject with a challenge. + Some(Err(())) => Ok(entry_point.commence(&req, "Malformed Basic credentials")), + Some(Ok((username, password))) => { + match manager + .authenticate(AuthenticationRequest::username_password(username, password)) + .await + { + Ok(auth) => { + req.extensions_mut().insert(auth.clone()); + // Scope the authentication for downstream method + // security / handlers, as BearerLayer does. + crate::with_authentication_scope(auth, inner.call(req)).await + } + Err(_) => Ok(entry_point.commence(&req, "Bad credentials")), + } + } + } + }) + } +} + +/// Parses an `Authorization: Basic ` header. +/// +/// * `None` — no header, or a non-`Basic` scheme (pass through). +/// * `Some(Err(()))` — a `Basic` header that is not valid base64 / UTF-8, or +/// carries no `:` separator (malformed). +/// * `Some(Ok((user, password)))` — decoded credentials. +fn parse_basic(headers: &HeaderMap) -> Option> { + let raw = headers.get(AUTHORIZATION)?.to_str().ok()?; + // The scheme token is case-insensitive (RFC 7617). + if !raw + .get(..6) + .is_some_and(|p| p.eq_ignore_ascii_case("Basic ")) + { + return None; + } + let encoded = raw[6..].trim(); + let Ok(bytes) = STANDARD.decode(encoded) else { + return Some(Err(())); + }; + let Ok(text) = String::from_utf8(bytes) else { + return Some(Err(())); + }; + match text.split_once(':') { + Some((user, password)) => Some(Ok((user.to_string(), password.to_string()))), + None => Some(Err(())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authentication_manager::ProviderManager; + use crate::password::{BcryptPasswordEncoder, PasswordEncoder}; + use crate::userdetails::{DaoAuthenticationProvider, InMemoryUserDetailsService, UserDetails}; + use std::sync::Mutex; + use tower::ServiceExt; + + fn manager() -> Arc { + let hash = BcryptPasswordEncoder::with_rounds(4).hash("pw").unwrap(); + let uds = Arc::new( + InMemoryUserDetailsService::new().with_user(UserDetails::new( + "alice", + hash, + vec!["USER".into()], + )), + ); + let provider = Arc::new(DaoAuthenticationProvider::new( + uds, + Arc::new(BcryptPasswordEncoder::with_rounds(4)), + )); + Arc::new(ProviderManager::new(vec![provider])) + } + + fn basic(user: &str, pass: &str) -> String { + format!("Basic {}", STANDARD.encode(format!("{user}:{pass}"))) + } + + async fn run(auth_header: Option<&str>) -> (Response, Option) { + let seen: Arc>> = Arc::new(Mutex::new(None)); + let probe = seen.clone(); + let inner = tower::service_fn(move |_req: Request| { + let probe = probe.clone(); + async move { + *probe.lock().unwrap() = crate::current_authentication().map(|a| a.principal); + Ok::(Response::new(axum::body::Body::empty())) + } + }); + let svc = HttpBasicLayer::new(manager()).realm("test").layer(inner); + let mut builder = Request::builder().uri("/x"); + if let Some(h) = auth_header { + builder = builder.header(AUTHORIZATION, h); + } + let resp = svc + .oneshot(builder.body(axum::body::Body::empty()).unwrap()) + .await + .unwrap(); + let principal = seen.lock().unwrap().clone(); + (resp, principal) + } + + #[tokio::test] + async fn valid_credentials_authenticate_and_scope() { + let (resp, principal) = run(Some(&basic("alice", "pw"))).await; + assert_eq!(resp.status(), http::StatusCode::OK); + assert_eq!(principal.as_deref(), Some("alice")); + } + + #[tokio::test] + async fn invalid_credentials_get_a_basic_challenge() { + let (resp, principal) = run(Some(&basic("alice", "wrong"))).await; + assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); + let challenge = resp + .headers() + .get(http::header::WWW_AUTHENTICATE) + .unwrap() + .to_str() + .unwrap(); + assert!(challenge.starts_with("Basic realm=\"test\""), "{challenge}"); + // The inner handler never ran. + assert_eq!(principal, None); + } + + #[tokio::test] + async fn absent_header_passes_through() { + let (resp, principal) = run(None).await; + assert_eq!(resp.status(), http::StatusCode::OK); + assert_eq!(principal, None); + } + + #[tokio::test] + async fn malformed_header_is_challenged() { + let (resp, _) = run(Some("Basic !!!not-base64!!!")).await; + assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); + // A non-Basic scheme passes through, though. + let (resp2, _) = run(Some("Bearer xyz")).await; + assert_eq!(resp2.status(), http::StatusCode::OK); + } +} diff --git a/crates/security/src/lib.rs b/crates/security/src/lib.rs index 57a6a40..5adc083 100644 --- a/crates/security/src/lib.rs +++ b/crates/security/src/lib.rs @@ -144,16 +144,22 @@ mod context; mod csrf; mod exception; mod filter_chain; +mod form_login; pub mod guards; +mod http_basic; mod jwks; mod jwt; pub mod oauth2; mod ott; mod password; mod problem; +mod remember_me; +mod request_cache; mod role_hierarchy; mod security_context; +mod security_filter_chains; mod session_auth; +mod session_policy; mod userdetails; #[cfg(feature = "webauthn")] mod webauthn; @@ -181,11 +187,15 @@ pub use csrf::{ CSRF_COOKIE_NAME, CSRF_HEADER_NAME, SAFE_METHODS, }; pub use exception::{ - AccessDeniedHandler, AuthenticationEntryPoint, ProblemAccessDeniedHandler, - ProblemAuthenticationEntryPoint, + AccessDeniedHandler, AuthenticationEntryPoint, BasicAuthenticationEntryPoint, + ProblemAccessDeniedHandler, ProblemAuthenticationEntryPoint, }; pub use filter_chain::{FilterChain, FilterChainLayer, FilterChainService, Rule}; +pub use form_login::{ + form_login_routes, FormLoginFailureHandler, FormLoginState, FormLoginSuccessHandler, +}; pub use guards::{require, AuthorizationGuard}; +pub use http_basic::{HttpBasicLayer, HttpBasicService}; pub use jwks::{claims_to_authentication, Algorithm, JwksVerifier, DEFAULT_CLOCK_SKEW_SECONDS}; pub use jwt::{authentication_from_claims, JwtService, DEFAULT_EXPIRATION_SECONDS}; pub use ott::{ @@ -197,14 +207,26 @@ pub use password::{ Argon2PasswordEncoder, BcryptPasswordEncoder, DelegatingPasswordEncoder, NoOpPasswordEncoder, PasswordEncoder, DEFAULT_PASSWORD_ENCODER_ID, DEFAULT_ROUNDS, }; +pub use remember_me::{ + RememberMeServices, TokenBasedRememberMeServices, DEFAULT_REMEMBER_ME_SECONDS, +}; +pub use request_cache::{ + HttpSessionRequestCache, NullRequestCache, RequestCache, SavedRequest, + SESSION_KEY_SAVED_REQUEST, +}; pub use role_hierarchy::RoleHierarchy; pub use security_context::{ HttpSessionSecurityContextRepository, NullSecurityContextRepository, SecurityContextRepository, }; +pub use security_filter_chains::{ + AnyRequestMatcher, PathRequestMatcher, RequestMatcher, SecurityFilterChains, + SecurityFilterChainsLayer, SecurityFilterChainsService, +}; pub use session_auth::{ SessionAuthenticationLayer, SessionAuthenticationService, SessionLoginSession, SessionLoginSessionStore, }; +pub use session_policy::SessionCreationPolicy; pub use userdetails::{ AccountStatusUserDetailsChecker, DaoAuthenticationProvider, InMemoryUserDetailsService, UserDetails, UserDetailsChecker, UserDetailsService, diff --git a/crates/security/src/remember_me.rs b/crates/security/src/remember_me.rs new file mode 100644 index 0000000..3b3d42f --- /dev/null +++ b/crates/security/src/remember_me.rs @@ -0,0 +1,246 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Remember-me authentication — the Rust analog of Spring Security's +//! `rememberMe()` (`TokenBasedRememberMeServices`). +//! +//! [`TokenBasedRememberMeServices`] mints a signed, expiring token whose +//! signature is an **HMAC-SHA256** keyed by a server secret over the username, +//! an expiry, and the user's stored password hash — so the token auto-expires, +//! can't be forged without the key, and is invalidated by a password change. +//! [`auto_login`](RememberMeServices::auto_login) +//! validates the token (signature + expiry, against the +//! [`UserDetailsService`](crate::UserDetailsService)) and returns an +//! [`Authentication`] marked **remembered** (`is_remembered()` → `true`, +//! `is_fully_authenticated()` → `false`), so a sensitive route can demand a +//! fresh login. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, Mac}; +use serde_json::Value; +use sha2::Sha256; + +use crate::authentication::{Authentication, REMEMBERED_CLAIM}; +use crate::csrf::constant_time_eq; +use crate::userdetails::UserDetailsService; + +/// Default remember-me token lifetime — 14 days, matching Spring's +/// `AbstractRememberMeServices.TWO_WEEKS_S`. +pub const DEFAULT_REMEMBER_ME_SECONDS: u64 = 14 * 24 * 60 * 60; + +/// Mints and validates remember-me tokens — Spring's `RememberMeServices`. +#[async_trait] +pub trait RememberMeServices: Send + Sync { + /// Validates a remember-me token (cookie value) and returns the + /// **remembered** [`Authentication`], or `None` when the token is invalid, + /// expired, forged, or the user no longer exists. + async fn auto_login(&self, token: &str) -> Option; +} + +/// Hash-based remember-me — Spring's `TokenBasedRememberMeServices`. +/// +/// Token = `base64url(username:expiry:signature)` where `signature = +/// base64url(HMAC-SHA256(key, "username:expiry:password"))`. Using an HMAC keyed +/// by the server `key` (rather than hashing the key as a message field) gives a +/// proper keyed MAC with no length-extension or delimiter-injection concerns; +/// binding the user's stored password hash means changing the password +/// invalidates every outstanding token, and the `key` means only this server +/// can mint one. +pub struct TokenBasedRememberMeServices { + key: String, + token_validity_seconds: u64, + user_details_service: Arc, +} + +impl TokenBasedRememberMeServices { + /// Builds the service with `key` (server secret) over `user_details_service`, + /// using the default 14-day validity. + #[must_use] + pub fn new(key: impl Into, user_details_service: Arc) -> Self { + Self { + key: key.into(), + token_validity_seconds: DEFAULT_REMEMBER_ME_SECONDS, + user_details_service, + } + } + + /// Overrides the token validity (seconds). + #[must_use] + pub fn token_validity_seconds(mut self, seconds: u64) -> Self { + self.token_validity_seconds = seconds; + self + } + + /// Mints a remember-me token (cookie value) for `username`, signed with the + /// user's stored `password` hash. Call on a successful login when the user + /// opted in to "remember me". + #[must_use] + pub fn make_token(&self, username: &str, password: &str) -> String { + let expiry = now_secs() + .unwrap_or(0) + .saturating_add(self.token_validity_seconds); + let signature = self.sign(username, expiry, password); + URL_SAFE_NO_PAD.encode(format!("{username}:{expiry}:{signature}")) + } + + /// The HMAC-SHA256 signature, keyed by the server `key`, over the message + /// `username:expiry:password`. + fn sign(&self, username: &str, expiry: u64, password: &str) -> String { + let mut mac = Hmac::::new_from_slice(self.key.as_bytes()) + .expect("HMAC accepts a key of any length"); + mac.update(format!("{username}:{expiry}:{password}").as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + } +} + +#[async_trait] +impl RememberMeServices for TokenBasedRememberMeServices { + async fn auto_login(&self, token: &str) -> Option { + let decoded = URL_SAFE_NO_PAD.decode(token).ok()?; + let decoded = String::from_utf8(decoded).ok()?; + // Parse from the right so a username may itself contain ':'. + let mut parts = decoded.rsplitn(3, ':'); + let signature = parts.next()?; + let expiry: u64 = parts.next()?.parse().ok()?; + let username = parts.next()?; + // Fail closed on a clock error (a pre-UNIX-EPOCH clock): `?` rejects. + if now_secs()? > expiry { + return None; + } + let user = self + .user_details_service + .load_user_by_username(username) + .await + .ok()??; + let expected = self.sign(username, expiry, &user.password); + if !constant_time_eq(signature.as_bytes(), expected.as_bytes()) { + return None; + } + let mut auth = user.to_authentication(); + // Mark the context as remember-me (not fully authenticated). + auth.claims + .insert(REMEMBERED_CLAIM.to_string(), Value::Bool(true)); + Some(auth) + } +} + +/// The current wall-clock time in epoch seconds, or `None` if the system clock +/// is set before the UNIX epoch. Validation treats `None` as "reject" (fail +/// closed) so a broken clock can never disable the expiry check. +fn now_secs() -> Option { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::userdetails::{InMemoryUserDetailsService, UserDetails}; + + const KEY: &str = "server-secret-key"; + + fn service() -> TokenBasedRememberMeServices { + let uds = Arc::new( + InMemoryUserDetailsService::new() + // The "password" here is the stored hash used in the signature. + .with_user(UserDetails::new( + "alice", + "stored-hash-v1", + vec!["USER".into()], + )), + ); + TokenBasedRememberMeServices::new(KEY, uds) + } + + #[tokio::test] + async fn token_round_trips_and_marks_remembered() { + let svc = service(); + let token = svc.make_token("alice", "stored-hash-v1"); + let auth = svc.auto_login(&token).await.expect("auto-login"); + assert_eq!(auth.principal, "alice"); + assert!(auth.has_role("USER")); + // Remember-me is authenticated, but NOT fully authenticated. + assert!(auth.is_authenticated()); + assert!(auth.is_remembered()); + assert!(!auth.is_fully_authenticated()); + } + + #[tokio::test] + async fn expired_token_is_rejected() { + let svc = service().token_validity_seconds(0); + // Forge an already-past expiry directly (deterministic). + let past = now_secs().unwrap().saturating_sub(10); + let sig = svc.sign("alice", past, "stored-hash-v1"); + let token = URL_SAFE_NO_PAD.encode(format!("alice:{past}:{sig}")); + assert!(svc.auto_login(&token).await.is_none()); + } + + #[tokio::test] + async fn tampered_and_wrong_key_tokens_are_rejected() { + let svc = service(); + let token = svc.make_token("alice", "stored-hash-v1"); + + // Flip a character in the encoded token. + let mut raw = String::from_utf8(URL_SAFE_NO_PAD.decode(&token).unwrap()).unwrap(); + raw.push('x'); + let tampered = URL_SAFE_NO_PAD.encode(raw); + assert!(svc.auto_login(&tampered).await.is_none()); + + // A token minted with a different key does not verify here. + let other = TokenBasedRememberMeServices::new( + "different-key", + Arc::new( + InMemoryUserDetailsService::new().with_user(UserDetails::new( + "alice", + "stored-hash-v1", + vec![], + )), + ), + ); + let foreign = other.make_token("alice", "stored-hash-v1"); + assert!(svc.auto_login(&foreign).await.is_none()); + } + + #[tokio::test] + async fn password_change_invalidates_existing_tokens() { + // Mint a token against the old stored hash... + let token = service().make_token("alice", "stored-hash-v1"); + // ...but the user store now holds a new hash (password changed). + let uds = Arc::new( + InMemoryUserDetailsService::new().with_user(UserDetails::new( + "alice", + "stored-hash-v2", + vec![], + )), + ); + let svc = TokenBasedRememberMeServices::new(KEY, uds); + assert!(svc.auto_login(&token).await.is_none()); + } + + #[tokio::test] + async fn unknown_user_token_is_rejected() { + let svc = service(); + let future = now_secs().unwrap() + 100; + let sig = svc.sign("ghost", future, "x"); + let token = URL_SAFE_NO_PAD.encode(format!("ghost:{future}:{sig}")); + assert!(svc.auto_login(&token).await.is_none()); + } +} diff --git a/crates/security/src/request_cache.rs b/crates/security/src/request_cache.rs new file mode 100644 index 0000000..aa47858 --- /dev/null +++ b/crates/security/src/request_cache.rs @@ -0,0 +1,308 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Request cache — the Rust analog of Spring Security's `RequestCache` / +//! `SavedRequest` (`HttpSessionRequestCache`). +//! +//! When an unauthenticated user hits a protected resource, the entry point +//! redirects them to log in — but the resource they *wanted* must be +//! remembered so they can be sent back there afterwards. A [`RequestCache`] +//! [`save_request`](RequestCache::save_request)s the original request before the +//! redirect; the form-login success path +//! ([`form_login_routes`](crate::form_login_routes)) then prefers the +//! [`SavedRequest`]'s URL over its configured default target — Spring's +//! `SavedRequestAwareAuthenticationSuccessHandler`. +//! +//! * [`HttpSessionRequestCache`] (default) — stores the [`SavedRequest`] as a +//! `firefly_session::Session` attribute (Spring's `HttpSessionRequestCache`). +//! * [`NullRequestCache`] — never stores, for stateless APIs that have no +//! post-login redirect (Spring's `NullRequestCache`). + +use async_trait::async_trait; +use axum::extract::Request; +use serde::{Deserialize, Serialize}; + +use firefly_session::Session; + +/// Session attribute key under which the [`SavedRequest`] is stored — the +/// Firefly analog of Spring's `SPRING_SECURITY_SAVED_REQUEST`. +pub const SESSION_KEY_SAVED_REQUEST: &str = "firefly:savedRequest"; + +/// A snapshot of the request a user tried to reach before being sent to log in +/// — Spring's `SavedRequest`. Captures enough to redirect the user back +/// ([`redirect_url`](Self::redirect_url)) and to recognize a replay +/// ([`matches`](Self::matches)). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedRequest { + /// The HTTP method (e.g. `"GET"`). + pub method: String, + /// The request target — path plus query string (e.g. `"/dashboard?tab=1"`). + pub uri: String, +} + +impl SavedRequest { + /// Builds a saved request from an explicit method and target. + #[must_use] + pub fn new(method: impl Into, uri: impl Into) -> Self { + Self { + method: method.into(), + uri: uri.into(), + } + } + + /// Captures the method and path+query of `request`. + #[must_use] + pub fn from_request(request: &Request) -> Self { + Self { + method: request.method().as_str().to_owned(), + uri: request_target(request), + } + } + + /// The URL to redirect the user back to after login — Spring's + /// `SavedRequest.getRedirectUrl()`. + #[must_use] + pub fn redirect_url(&self) -> &str { + &self.uri + } + + /// Whether [`redirect_url`](Self::redirect_url) is a safe **same-origin** + /// target: a rooted absolute path (`/…`) that is neither protocol-relative + /// (`//host`) nor backslash-tricked (`/\host`, which some browsers treat as + /// `//host`), and contains no control characters (which would enable header + /// injection). A post-login redirect should honour the saved request only + /// when this holds, so a crafted off-site or header-splitting path can never + /// turn the login flow into an open redirect. + #[must_use] + pub fn is_safe_redirect(&self) -> bool { + let p = self.uri.as_bytes(); + p.first() == Some(&b'/') + && p.get(1) != Some(&b'/') + && p.get(1) != Some(&b'\\') + && !self.uri.bytes().any(|b| b.is_ascii_control()) + } + + /// Whether `incoming` is the same request that was saved (method + target), + /// used by [`RequestCache::get_matching_request`] to recognize a replay. + #[must_use] + pub fn matches(&self, incoming: &SavedRequest) -> bool { + self == incoming + } +} + +/// Saves and restores the pre-login [`SavedRequest`] — Spring's `RequestCache`. +/// +/// The methods take an owned [`SavedRequest`] (build one from the live request +/// with [`SavedRequest::from_request`]) rather than a `&Request`: an +/// `axum::extract::Request` is not `Sync` (its streaming body), so it cannot be +/// held across the `.await` in an async-trait method. +#[async_trait] +pub trait RequestCache: Send + Sync { + /// Stores `request` so it can be restored after the user authenticates. + async fn save_request(&self, session: &Session, request: SavedRequest); + + /// Returns the stored request, if any (without removing it). + async fn get_request(&self, session: &Session) -> Option; + + /// Discards any stored request. + async fn remove_request(&self, session: &Session); + + /// If the stored request matches `incoming`, removes and returns it (a + /// replay); otherwise leaves the cache untouched and returns `None`. + async fn get_matching_request( + &self, + session: &Session, + incoming: &SavedRequest, + ) -> Option { + let saved = self.get_request(session).await?; + if saved.matches(incoming) { + self.remove_request(session).await; + Some(saved) + } else { + None + } + } +} + +/// Session-backed request cache — Spring's `HttpSessionRequestCache`. +/// +/// Stores the [`SavedRequest`] under a session attribute key (default +/// [`SESSION_KEY_SAVED_REQUEST`]). +#[derive(Debug, Clone)] +pub struct HttpSessionRequestCache { + key: String, +} + +impl HttpSessionRequestCache { + /// Builds the cache keyed on the default [`SESSION_KEY_SAVED_REQUEST`] + /// attribute. + #[must_use] + pub fn new() -> Self { + Self { + key: SESSION_KEY_SAVED_REQUEST.to_owned(), + } + } + + /// Builds the cache keyed on a custom session attribute. + #[must_use] + pub fn with_key(key: impl Into) -> Self { + Self { key: key.into() } + } +} + +impl Default for HttpSessionRequestCache { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl RequestCache for HttpSessionRequestCache { + async fn save_request(&self, session: &Session, request: SavedRequest) { + let _ = session.set_attribute(&self.key, request).await; + } + + async fn get_request(&self, session: &Session) -> Option { + session.attribute::(&self.key).await + } + + async fn remove_request(&self, session: &Session) { + session.remove_attribute(&self.key).await; + } +} + +/// A request cache that never stores — Spring's `NullRequestCache`, for +/// stateless APIs with no post-login redirect. +#[derive(Debug, Clone, Copy, Default)] +pub struct NullRequestCache; + +#[async_trait] +impl RequestCache for NullRequestCache { + async fn save_request(&self, _session: &Session, _request: SavedRequest) {} + async fn get_request(&self, _session: &Session) -> Option { + None + } + async fn remove_request(&self, _session: &Session) {} +} + +/// The request target — path plus query string when present, else just the path. +fn request_target(request: &Request) -> String { + request.uri().path_and_query().map_or_else( + || request.uri().path().to_owned(), + |pq| pq.as_str().to_owned(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use firefly_session::SessionInner; + + fn get(uri: &str) -> SavedRequest { + SavedRequest::from_request( + &Request::builder() + .method(http::Method::GET) + .uri(uri) + .body(axum::body::Body::empty()) + .unwrap(), + ) + } + + #[tokio::test] + async fn from_request_captures_method_and_path_with_query() { + let saved = get("/dashboard?tab=1"); + assert_eq!(saved.method, "GET"); + assert_eq!(saved.uri, "/dashboard?tab=1"); + assert_eq!(saved.redirect_url(), "/dashboard?tab=1"); + } + + #[test] + fn is_safe_redirect_rejects_off_site_targets() { + // Same-origin rooted paths are safe. + assert!(SavedRequest::new("GET", "/").is_safe_redirect()); + assert!(SavedRequest::new("GET", "/dashboard?tab=1").is_safe_redirect()); + // Protocol-relative, backslash-tricked, absolute, empty, and + // control-char (header-injection) targets are not. + assert!(!SavedRequest::new("GET", "//evil.com").is_safe_redirect()); + assert!(!SavedRequest::new("GET", "/\\evil.com").is_safe_redirect()); + assert!(!SavedRequest::new("GET", "https://evil.com").is_safe_redirect()); + assert!(!SavedRequest::new("GET", "").is_safe_redirect()); + assert!(!SavedRequest::new("GET", "/x\r\nSet-Cookie: evil=1").is_safe_redirect()); + } + + #[tokio::test] + async fn saves_and_restores_through_the_session() { + let cache = HttpSessionRequestCache::new(); + let session = Session::new(SessionInner::new("sid")); + + // Empty: nothing saved. + assert!(cache.get_request(&session).await.is_none()); + + cache.save_request(&session, get("/dashboard?tab=1")).await; + let saved = cache.get_request(&session).await.expect("saved"); + assert_eq!(saved.redirect_url(), "/dashboard?tab=1"); + + cache.remove_request(&session).await; + assert!(cache.get_request(&session).await.is_none()); + } + + #[tokio::test] + async fn saved_request_survives_session_id_rotation() { + // The request is saved on the pre-login request; the login POST rotates + // the session id (anti-fixation) and must still see the saved request. + let cache = HttpSessionRequestCache::new(); + let session = Session::new(SessionInner::new("sid")); + cache.save_request(&session, get("/account")).await; + session.rotate_id().await; + let saved = cache + .get_request(&session) + .await + .expect("survives rotation"); + assert_eq!(saved.redirect_url(), "/account"); + } + + #[tokio::test] + async fn get_matching_request_consumes_only_on_match() { + let cache = HttpSessionRequestCache::new(); + let session = Session::new(SessionInner::new("sid")); + cache.save_request(&session, get("/reports?y=2026")).await; + + // A non-matching target leaves the cache intact. + assert!(cache + .get_matching_request(&session, &get("/other")) + .await + .is_none()); + assert!(cache.get_request(&session).await.is_some()); + + // The matching request is returned and consumed. + let matched = cache + .get_matching_request(&session, &get("/reports?y=2026")) + .await + .expect("match"); + assert_eq!(matched.redirect_url(), "/reports?y=2026"); + assert!(cache.get_request(&session).await.is_none()); + } + + #[tokio::test] + async fn null_cache_never_stores() { + let cache = NullRequestCache; + let session = Session::new(SessionInner::new("sid")); + cache.save_request(&session, get("/x")).await; + assert!(cache.get_request(&session).await.is_none()); + assert!(cache + .get_matching_request(&session, &get("/x")) + .await + .is_none()); + } +} diff --git a/crates/security/src/security_filter_chains.rs b/crates/security/src/security_filter_chains.rs new file mode 100644 index 0000000..892bb28 --- /dev/null +++ b/crates/security/src/security_filter_chains.rs @@ -0,0 +1,400 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Multiple security filter chains — the Rust analog of Spring Security's +//! several `SecurityFilterChain` beans behind a `FilterChainProxy`. +//! +//! A real app often needs different authorization rules for different URL +//! spaces — a stateless, deny-by-default `/api/**` and a more permissive web +//! surface, say. [`SecurityFilterChains`] holds an *ordered* list of +//! ([`RequestMatcher`], [`FilterChain`](crate::FilterChain)) pairs; for each +//! request the **first** chain whose matcher matches handles it (and *only* +//! that chain runs) — Spring's first-match-wins `FilterChainProxy`. A request +//! that matches **no** chain passes through untouched (no authorization +//! applied), so declare a catch-all [`any`](SecurityFilterChains::any) chain +//! last when you want a fail-closed tail. +//! +//! This dispatches the *authorization* [`FilterChain`](crate::FilterChain) per +//! request. Authentication layers ([`BearerLayer`](crate::BearerLayer), +//! [`SessionAuthenticationLayer`](crate::SessionAuthenticationLayer)) compose +//! around it as usual; for fully distinct *authentication* per URL space, the +//! idiomatic complement is an axum `Router::nest` with per-router layers. +//! +//! ```rust,no_run +//! use firefly_security::{ +//! AnyRequestMatcher, FilterChain, PathRequestMatcher, SecurityFilterChains, +//! }; +//! +//! let security = SecurityFilterChains::new() +//! // /api/** — locked down, deny-by-default. +//! .chain( +//! PathRequestMatcher::new("/api"), +//! FilterChain::new().require_pattern("/api/**", &["API"]), +//! ) +//! // everything else — public. +//! .any(FilterChain::new().any_request_permit()) +//! .layer(); +//! // app.layer(security) ... +//! ``` + +use std::convert::Infallible; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::extract::Request; +use axum::response::Response; +use http::Method; +use tower::{Layer, Service, ServiceExt}; + +use crate::authentication::SecurityError; +use crate::filter_chain::{prefix_matches, FilterChain, FilterChainLayer, FilterChainService}; + +/// Decides whether a security filter chain applies to a request — Spring's +/// `RequestMatcher`. +pub trait RequestMatcher: Send + Sync { + /// Whether this matcher selects `request`. + fn matches(&self, request: &Request) -> bool; +} + +/// Matches every request — Spring's `AnyRequestMatcher`. Use it for a +/// catch-all (last) chain. +#[derive(Debug, Clone, Copy, Default)] +pub struct AnyRequestMatcher; + +impl RequestMatcher for AnyRequestMatcher { + fn matches(&self, _request: &Request) -> bool { + true + } +} + +/// Matches by path prefix (path-segment aware, like Spring's +/// `AntPathRequestMatcher`) and, optionally, an HTTP method. `/api` matches +/// `/api` and `/api/...` but not `/apixyz`. +#[derive(Debug, Clone)] +pub struct PathRequestMatcher { + method: Option, + prefix: String, +} + +impl PathRequestMatcher { + /// Matches any method under the path `prefix`. + #[must_use] + pub fn new(prefix: impl Into) -> Self { + Self { + method: None, + prefix: prefix.into(), + } + } + + /// Matches only `method` requests under the path `prefix`. + #[must_use] + pub fn method(method: Method, prefix: impl Into) -> Self { + Self { + method: Some(method), + prefix: prefix.into(), + } + } +} + +impl RequestMatcher for PathRequestMatcher { + fn matches(&self, request: &Request) -> bool { + if let Some(m) = &self.method { + if request.method() != m { + return false; + } + } + prefix_matches(request.uri().path(), &self.prefix) + } +} + +/// An ordered set of ([`RequestMatcher`], [`FilterChain`](crate::FilterChain)) +/// pairs — Spring's list of `SecurityFilterChain`s behind a `FilterChainProxy`. +/// The first matching chain handles each request. +#[derive(Default)] +pub struct SecurityFilterChains { + chains: Vec<(Arc, FilterChain)>, +} + +impl SecurityFilterChains { + /// An empty proxy (passes every request through until a chain is added). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Appends a chain guarded by `matcher`. Order matters: earlier chains win. + #[must_use] + pub fn chain(mut self, matcher: impl RequestMatcher + 'static, chain: FilterChain) -> Self { + self.chains.push((Arc::new(matcher), chain)); + self + } + + /// Appends a catch-all chain (matches every request) — Spring's + /// `securityMatcher` omitted. Declare it last. + #[must_use] + pub fn any(self, chain: FilterChain) -> Self { + self.chain(AnyRequestMatcher, chain) + } + + /// Compiles every chain into a dispatching tower [`Layer`]. + /// + /// # Panics + /// + /// Panics if any chain has an invalid glob pattern. Use + /// [`try_layer`](Self::try_layer) to surface that as a recoverable error. + #[must_use] + pub fn layer(self) -> SecurityFilterChainsLayer { + self.try_layer() + .expect("firefly/security: invalid glob pattern in a SecurityFilterChains chain") + } + + /// Compiles every chain into a dispatching tower [`Layer`], returning a + /// recoverable [`SecurityError`] if any chain has an invalid glob pattern. + pub fn try_layer(self) -> Result { + let mut compiled = Vec::with_capacity(self.chains.len()); + for (matcher, chain) in self.chains { + compiled.push((matcher, chain.try_layer()?)); + } + Ok(SecurityFilterChainsLayer { + chains: Arc::new(compiled), + }) + } +} + +/// The tower layer produced by [`SecurityFilterChains::layer`]. +#[derive(Clone)] +pub struct SecurityFilterChainsLayer { + chains: Arc, FilterChainLayer)>>, +} + +impl Layer for SecurityFilterChainsLayer +where + S: Clone, +{ + type Service = SecurityFilterChainsService; + + fn layer(&self, inner: S) -> Self::Service { + // Pre-apply each chain's authorization layer to its own clone of the + // inner service, so dispatch is a cheap matcher scan at request time. + let chains = self + .chains + .iter() + .map(|(matcher, layer)| (Arc::clone(matcher), layer.layer(inner.clone()))) + .collect(); + SecurityFilterChainsService { inner, chains } + } +} + +/// The tower service produced by [`SecurityFilterChainsLayer`]. Selects the +/// first chain whose [`RequestMatcher`] matches; if none match, passes the +/// request through to the inner service unmodified. +#[derive(Clone)] +pub struct SecurityFilterChainsService { + inner: S, + chains: Vec<(Arc, FilterChainService)>, +} + +impl Service for SecurityFilterChainsService +where + S: Service + Clone + Send + 'static, + S::Future: Send + 'static, +{ + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + // First-match-wins (Spring's FilterChainProxy): scan synchronously. + let selected = self.chains.iter().position(|(m, _)| m.matches(&req)); + match selected { + Some(i) => { + // Call a fresh clone of the chosen chain service, but honor + // tower's readiness contract first: `poll_ready` above only + // readied `self.inner` (used on the no-match branch), so drive + // this clone ready (its `poll_ready` readies its own wrapped + // inner) before `call` — correct even for a backpressure-bearing + // inner service, not only always-ready ones. + let mut svc = self.chains[i].1.clone(); + Box::pin(async move { svc.ready().await?.call(req).await }) + } + None => { + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); + Box::pin(async move { inner.call(req).await }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt; + + async fn status(proxy: SecurityFilterChains, method: &str, path: &str) -> http::StatusCode { + let inner = tower::service_fn(|_req: Request| async { + Ok::(Response::new(axum::body::Body::empty())) + }); + let svc = proxy.layer().layer(inner); + let req = Request::builder() + .method(method) + .uri(path) + .body(axum::body::Body::empty()) + .unwrap(); + svc.oneshot(req).await.unwrap().status() + } + + #[tokio::test] + async fn first_matching_chain_handles_the_request() { + // /api/** routes to a deny-all chain; everything else to a permit-all + // chain — proving the matcher selects which chain's rules apply. + let proxy = || { + SecurityFilterChains::new() + .chain( + PathRequestMatcher::new("/api"), + FilterChain::new().any_request_deny(), + ) + .any(FilterChain::new().any_request_permit()) + }; + assert_eq!( + status(proxy(), "GET", "/api/users").await, + http::StatusCode::FORBIDDEN + ); + assert_eq!( + status(proxy(), "GET", "/web/home").await, + http::StatusCode::OK + ); + } + + #[tokio::test] + async fn earlier_chain_wins_over_a_later_overlapping_one() { + // Both chains match /api; the first (deny) decides. + let proxy = SecurityFilterChains::new() + .chain( + PathRequestMatcher::new("/api"), + FilterChain::new().any_request_deny(), + ) + .any(FilterChain::new().any_request_permit()); + assert_eq!( + status(proxy, "GET", "/api/x").await, + http::StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn unmatched_request_passes_through() { + // Only an /api chain is declared; an unmatched path is served by the + // inner service (no authorization applied) — Spring's FilterChainProxy. + let proxy = SecurityFilterChains::new().chain( + PathRequestMatcher::new("/api"), + FilterChain::new().any_request_deny(), + ); + assert_eq!(status(proxy, "GET", "/other").await, http::StatusCode::OK); + // ...and the /api chain still denies. + assert_eq!( + status(proxy_again(), "GET", "/api/x").await, + http::StatusCode::FORBIDDEN + ); + } + + fn proxy_again() -> SecurityFilterChains { + SecurityFilterChains::new().chain( + PathRequestMatcher::new("/api"), + FilterChain::new().any_request_deny(), + ) + } + + #[tokio::test] + async fn method_scoped_matcher_selects_by_verb() { + let proxy = || { + SecurityFilterChains::new() + .chain( + PathRequestMatcher::method(Method::POST, "/data"), + FilterChain::new().any_request_deny(), + ) + .any(FilterChain::new().any_request_permit()) + }; + // POST /data → deny chain; GET /data → falls through to permit chain. + assert_eq!( + status(proxy(), "POST", "/data").await, + http::StatusCode::FORBIDDEN + ); + assert_eq!(status(proxy(), "GET", "/data").await, http::StatusCode::OK); + } + + #[test] + fn try_layer_surfaces_invalid_glob_as_error() { + let bad = SecurityFilterChains::new() + .any(FilterChain::new().require_pattern("/admin/[", &["ADMIN"])) + .try_layer(); + assert!(bad.is_err()); + } + + // An inner service that requires the tower readiness handshake: `call` + // panics unless `poll_ready` was driven on the same instance first (like + // tower's Buffer/ConcurrencyLimit, which reserve a permit in poll_ready). + #[derive(Clone)] + struct ReadinessGated { + ready: bool, + } + + impl Service for ReadinessGated { + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.ready = true; + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request) -> Self::Future { + assert!( + self.ready, + "call() invoked before poll_ready() — readiness contract violated" + ); + self.ready = false; + Box::pin(async { Ok(Response::new(axum::body::Body::empty())) }) + } + } + + #[tokio::test] + async fn matched_chain_drives_inner_to_readiness_before_calling() { + // Regression: the matched-chain dispatch path must drive the chosen + // chain service ready before calling it. With a readiness-gated inner, + // a missing handshake panics in `call`. + let proxy = SecurityFilterChains::new() + .chain( + PathRequestMatcher::new("/api"), + FilterChain::new().any_request_permit(), + ) + .layer(); + let svc = proxy.layer(ReadinessGated { ready: false }); + let req = Request::builder() + .method("GET") + .uri("/api/users") + .body(axum::body::Body::empty()) + .unwrap(); + assert_eq!( + svc.oneshot(req).await.unwrap().status(), + http::StatusCode::OK + ); + } +} diff --git a/crates/security/src/session_auth.rs b/crates/security/src/session_auth.rs index a526fd6..cfe4376 100644 --- a/crates/security/src/session_auth.rs +++ b/crates/security/src/session_auth.rs @@ -72,6 +72,7 @@ use firefly_session::{ use crate::authentication::Authentication; use crate::oauth2::{LoginSession, LoginSessionStore}; use crate::security_context::{HttpSessionSecurityContextRepository, SecurityContextRepository}; +use crate::session_policy::SessionCreationPolicy; /// Tower [`Layer`] that restores an [`Authentication`] from the request's /// [`firefly_session::Session`] — the Rust port of pyfly's @@ -142,6 +143,21 @@ impl SessionAuthenticationLayer { self.repository = repository; self } + + /// Selects the [`SecurityContextRepository`] from a + /// [`SessionCreationPolicy`] — Spring's + /// `sessionManagement().sessionCreationPolicy(...)`. + /// [`Stateless`](SessionCreationPolicy::Stateless) installs the + /// [`NullSecurityContextRepository`](crate::NullSecurityContextRepository) + /// (no session context); the others keep the session-backed repository. For + /// a token-only API, combine with + /// [`anonymous_fallback(false)`](Self::anonymous_fallback) so a following + /// [`BearerLayer`](crate::BearerLayer) governs the request. + #[must_use] + pub fn session_creation_policy(mut self, policy: SessionCreationPolicy) -> Self { + self.repository = policy.security_context_repository(); + self + } } impl Default for SessionAuthenticationLayer { @@ -641,6 +657,57 @@ mod tests { ); } + // T2.5: SessionCreationPolicy::Stateless installs the Null repository, so a + // session that *does* hold a context is ignored (Spring's STATELESS) — the + // same proof as `with_repository_swaps_the_context_source`, via the policy. + #[tokio::test] + async fn stateless_policy_ignores_session_context() { + use std::sync::Mutex; + use tower::ServiceExt; + + let session = Session::new(SessionInner::new("sid")); + let auth = Authentication { + principal: "u1".into(), + ..Default::default() + }; + session + .set_attribute( + SESSION_KEY_SECURITY_CONTEXT, + serde_json::to_string(&auth).unwrap(), + ) + .await + .unwrap(); + + let seen: Arc>> = Arc::new(Mutex::new(None)); + let probe = seen.clone(); + let inner = tower::service_fn(move |_req: Request| { + let probe = probe.clone(); + async move { + *probe.lock().unwrap() = crate::current_authentication().map(|a| a.principal); + Ok::(Response::new(axum::body::Body::empty())) + } + }); + + let mut req = Request::new(axum::body::Body::empty()); + req.extensions_mut().insert(session); + + let _ = SessionAuthenticationLayer::new() + .session_creation_policy(SessionCreationPolicy::Stateless) + // A token API would disable the anonymous fallback; keep it on here + // to prove the *stored* context is what gets ignored. + .layer(inner) + .oneshot(req) + .await + .unwrap(); + + // STATELESS reads nothing from the session, so the anonymous fallback + // applies and the stored "u1" is NOT seen. + assert_eq!( + seen.lock().unwrap().as_deref(), + Some(crate::authentication::ANONYMOUS_ID) + ); + } + // H1 (anonymous path): with the default anonymous fallback, the layer // should scope an anonymous context so downstream method security sees a // present-but-anonymous principal (Spring's AnonymousAuthenticationFilter). diff --git a/crates/security/src/session_policy.rs b/crates/security/src/session_policy.rs new file mode 100644 index 0000000..aafe905 --- /dev/null +++ b/crates/security/src/session_policy.rs @@ -0,0 +1,156 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Session creation policy — the Rust analog of Spring Security's +//! `SessionCreationPolicy` (`sessionManagement().sessionCreationPolicy(...)`). +//! +//! The policy governs whether the security layer reads/writes the +//! [`Authentication`](crate::Authentication) context from the HTTP session. It +//! maps to a [`SecurityContextRepository`]: +//! +//! * [`Always`](SessionCreationPolicy::Always) / +//! [`IfRequired`](SessionCreationPolicy::IfRequired) (default) / +//! [`Never`](SessionCreationPolicy::Never) — the session-backed +//! [`HttpSessionSecurityContextRepository`]: an established context survives +//! across requests. +//! * [`Stateless`](SessionCreationPolicy::Stateless) — the +//! [`NullSecurityContextRepository`]: nothing is read from or written to the +//! session, so each request must re-authenticate (e.g. a bearer token). Pair +//! with [`SessionAuthenticationLayer::anonymous_fallback(false)`](crate::SessionAuthenticationLayer::anonymous_fallback) +//! so a following [`BearerLayer`](crate::BearerLayer) governs the request. +//! +//! Whether a *new* session is actually minted is the +//! [`firefly_session::SessionLayer`]'s concern; this policy controls only +//! whether the security tier persists its context there. + +use std::sync::Arc; + +use crate::security_context::{ + HttpSessionSecurityContextRepository, NullSecurityContextRepository, SecurityContextRepository, +}; + +/// How the security tier uses the HTTP session to store the authenticated +/// context — Spring's `SessionCreationPolicy`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionCreationPolicy { + /// Always use the session to hold the context (Spring `ALWAYS`). + Always, + /// Use the session only when needed — the default (Spring `IF_REQUIRED`). + IfRequired, + /// Use an existing session but never create one to hold the context + /// (Spring `NEVER`). + Never, + /// Never read or write the session — every request re-authenticates + /// (Spring `STATELESS`); for token-only APIs. + Stateless, +} + +impl Default for SessionCreationPolicy { + /// Spring's default is `IF_REQUIRED`. + fn default() -> Self { + Self::IfRequired + } +} + +impl SessionCreationPolicy { + /// Whether the security tier reads/writes its context in the session + /// (`false` only for [`Stateless`](Self::Stateless)). + #[must_use] + pub fn uses_session(self) -> bool { + !matches!(self, Self::Stateless) + } + + /// Whether a *new* session may be created to hold the context (`true` for + /// [`Always`](Self::Always) / [`IfRequired`](Self::IfRequired)). + #[must_use] + pub fn allows_session_creation(self) -> bool { + matches!(self, Self::Always | Self::IfRequired) + } + + /// Whether this is the [`Stateless`](Self::Stateless) policy. + #[must_use] + pub fn is_stateless(self) -> bool { + matches!(self, Self::Stateless) + } + + /// The [`SecurityContextRepository`] implied by this policy: the + /// [`NullSecurityContextRepository`] for [`Stateless`](Self::Stateless) + /// (nothing persisted), the session-backed + /// [`HttpSessionSecurityContextRepository`] otherwise. + #[must_use] + pub fn security_context_repository(self) -> Arc { + if self.is_stateless() { + Arc::new(NullSecurityContextRepository) + } else { + Arc::new(HttpSessionSecurityContextRepository::new()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authentication::Authentication; + use firefly_session::{Session, SessionInner}; + + #[test] + fn default_is_if_required() { + assert_eq!( + SessionCreationPolicy::default(), + SessionCreationPolicy::IfRequired + ); + } + + #[test] + fn predicates_match_spring_semantics() { + use SessionCreationPolicy::{Always, IfRequired, Never, Stateless}; + + // uses_session: everything except STATELESS. + assert!(Always.uses_session()); + assert!(IfRequired.uses_session()); + assert!(Never.uses_session()); + assert!(!Stateless.uses_session()); + + // allows_session_creation: ALWAYS / IF_REQUIRED only. + assert!(Always.allows_session_creation()); + assert!(IfRequired.allows_session_creation()); + assert!(!Never.allows_session_creation()); + assert!(!Stateless.allows_session_creation()); + + assert!(Stateless.is_stateless()); + assert!(!Never.is_stateless()); + } + + #[tokio::test] + async fn stateless_repository_never_persists_but_others_do() { + let auth = Authentication { + principal: "u1".into(), + username: "u1".into(), + roles: vec!["USER".into()], + ..Default::default() + }; + + // STATELESS → Null repo: a stored context is not read back. + let stateless = SessionCreationPolicy::Stateless.security_context_repository(); + let s1 = Session::new(SessionInner::new("sid")); + stateless.save(&s1, &auth).await; + assert!(stateless.load(&s1).await.is_none()); + + // IF_REQUIRED → session repo: the context round-trips. + let stateful = SessionCreationPolicy::IfRequired.security_context_repository(); + let s2 = Session::new(SessionInner::new("sid")); + stateful.save(&s2, &auth).await; + assert_eq!(stateful.load(&s2).await.expect("loaded").principal, "u1"); + } +} diff --git a/crates/security/tests/web_mechanisms_test.rs b/crates/security/tests/web_mechanisms_test.rs new file mode 100644 index 0000000..db8a1a8 --- /dev/null +++ b/crates/security/tests/web_mechanisms_test.rs @@ -0,0 +1,209 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! End-to-end integration tests for the Spring Security **Tier 2** web +//! mechanisms, composed through a real `axum::Router` and driven with +//! `tower::ServiceExt::oneshot` (no sockets). These exercise the full stack — +//! the authentication mechanism populating the context, the `FilterChain` / +//! `SecurityFilterChains` authorizing against it, and the handler reading it — +//! which the per-module unit tests cover only in isolation. + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::Request; +use axum::http::{header, StatusCode}; +use axum::routing::get; +use axum::{Extension, Router}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use firefly_security::{ + Authentication, AuthenticationManager, BcryptPasswordEncoder, DaoAuthenticationProvider, + FilterChain, HttpBasicLayer, InMemoryUserDetailsService, PasswordEncoder, PathRequestMatcher, + ProviderManager, SecurityFilterChains, TokenBasedRememberMeServices, UserDetails, +}; + +/// A `ProviderManager` over an in-memory `alice`/`pw` user with `ROLE_USER`. +fn manager() -> Arc { + let hash = BcryptPasswordEncoder::with_rounds(4).hash("pw").unwrap(); + let uds = Arc::new( + InMemoryUserDetailsService::new().with_user(UserDetails::new( + "alice", + hash, + vec!["USER".into()], + )), + ); + let provider = Arc::new(DaoAuthenticationProvider::new( + uds, + Arc::new(BcryptPasswordEncoder::with_rounds(4)), + )); + Arc::new(ProviderManager::new(vec![provider])) +} + +fn basic_header(user: &str, pass: &str) -> String { + format!("Basic {}", STANDARD.encode(format!("{user}:{pass}"))) +} + +async fn body_string(resp: axum::response::Response) -> String { + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +/// HTTP Basic + RBAC `FilterChain` end-to-end: the Basic layer authenticates +/// and scopes the context, then the chain authorizes the protected route. +#[tokio::test] +async fn http_basic_then_filter_chain_authorizes_a_protected_route() { + // Layers run outermost-last: Basic runs first (populates the context), + // then the chain authorizes against it. + let app: Router = Router::new() + .route( + "/api/me", + get(|Extension(auth): Extension| async move { auth.principal }), + ) + .layer(FilterChain::new().require("/api", &["USER"]).layer()) + .layer(HttpBasicLayer::new(manager()).realm("test")); + + // Valid credentials -> the handler runs and sees the principal. + let ok = app + .clone() + .oneshot( + Request::builder() + .uri("/api/me") + .header(header::AUTHORIZATION, basic_header("alice", "pw")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(ok.status(), StatusCode::OK); + assert_eq!(body_string(ok).await, "alice"); + + // Bad credentials -> 401 Basic challenge, handler never runs. + let bad = app + .clone() + .oneshot( + Request::builder() + .uri("/api/me") + .header(header::AUTHORIZATION, basic_header("alice", "wrong")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(bad.status(), StatusCode::UNAUTHORIZED); + assert!(bad + .headers() + .get(header::WWW_AUTHENTICATE) + .unwrap() + .to_str() + .unwrap() + .starts_with("Basic realm=\"test\"")); + + // No credentials -> the Basic layer passes through and the chain denies + // (deny-by-default for an authenticated-only route). + let none = app + .oneshot( + Request::builder() + .uri("/api/me") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(none.status(), StatusCode::UNAUTHORIZED); +} + +/// Multiple filter chains end-to-end through a real Router: `/api/**` is locked +/// down (deny-by-default, no public rule) while the web surface is permitted. +#[tokio::test] +async fn security_filter_chains_route_by_matcher() { + let security = SecurityFilterChains::new() + .chain( + PathRequestMatcher::new("/api"), + FilterChain::new().any_request_authenticated(), + ) + .any(FilterChain::new().any_request_permit()) + .layer(); + + let app: Router = Router::new() + .route("/api/data", get(|| async { "secret" })) + .route("/", get(|| async { "home" })) + .layer(security); + + // The web surface is public. + let home = app + .clone() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(home.status(), StatusCode::OK); + assert_eq!(body_string(home).await, "home"); + + // /api/** requires authentication; an anonymous request is rejected by the + // first (api) chain, not served as public. + let api = app + .oneshot( + Request::builder() + .uri("/api/data") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(api.status(), StatusCode::UNAUTHORIZED); +} + +/// Remember-me end-to-end: a minted token auto-logs-in as a *remembered* +/// (not fully authenticated) principal, and a sensitive route can reject it. +#[tokio::test] +async fn remember_me_auto_login_is_not_fully_authenticated() { + let uds = Arc::new( + InMemoryUserDetailsService::new().with_user(UserDetails::new( + "alice", + "stored-hash", + vec!["USER".into()], + )), + ); + let svc = TokenBasedRememberMeServices::new("server-key", uds); + + let token = svc.make_token("alice", "stored-hash"); + let auth = { + use firefly_security::RememberMeServices; + svc.auto_login(&token).await.expect("auto-login") + }; + + assert_eq!(auth.principal, "alice"); + assert!(auth.is_authenticated()); + // Remembered, so a route guarded on "fully authenticated" must reject it. + assert!(auth.is_remembered()); + assert!(!auth.is_fully_authenticated()); + + // A token signed with a different server key does not auto-login. + let foreign = TokenBasedRememberMeServices::new( + "other-key", + Arc::new( + InMemoryUserDetailsService::new().with_user(UserDetails::new( + "alice", + "stored-hash", + vec![], + )), + ), + ) + .make_token("alice", "stored-hash"); + use firefly_security::RememberMeServices; + assert!(svc.auto_login(&foreign).await.is_none()); +} diff --git a/docs/book/build/md.py b/docs/book/build/md.py index cc7479a..25445e3 100644 --- a/docs/book/build/md.py +++ b/docs/book/build/md.py @@ -96,6 +96,33 @@ 'transform="rotate(120 10 10)"/>', } +# Professional inline SVG status icons for capability/coverage matrices (no +# emoji). Authored in Markdown as the tokens `:status-supported:`, +# `:status-partial:`, `:status-planned:` and substituted into the rendered HTML +# (see `render_markdown`); the distinct shapes (filled check / half disc / dashed +# ring) carry meaning without relying on colour alone. Each carries a for +# assistive technology. +_STATUS_ICON = { + ":status-supported:": + '<svg class="status-ico status-supported" xmlns="http://www.w3.org/2000/svg" ' + 'viewBox="0 0 20 20" role="img" aria-label="Supported"><title>Supported' + '' + '', + ":status-partial:": + '' + 'Partial — opt-in module' + '' + '', + ":status-planned:": + '' + 'Roadmap — planned' + '', +} + _FENCE_RE = re.compile(r"^```+([^\n`]*)$") # leading "**Note**" / "**Spring parity.**" / "**Spring parity:**" possibly # followed by "—" / "-" / ":". The chapter style ends the bold leader with a @@ -244,4 +271,11 @@ def render_markdown(text: str, base: Path) -> str: extensions=["extra", "sane_lists", _FireflyExtension()], output_format="xhtml", ) - return _to_xml_entities(md.convert(text)) + out = _to_xml_entities(md.convert(text)) + # Substitute status-icon tokens with inline SVG after rendering, so the SVG + # is never mangled by the Markdown inline parser (the tokens pass through as + # literal text inside table cells). The SVG has no named entities, so it is + # already well-formed XML for the EPUB build. + for token, svg in _STATUS_ICON.items(): + out = out.replace(token, svg) + return out diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub index 6586ee8..0568ec0 100644 Binary files a/docs/book/dist/firefly-rust-by-example-es.epub and b/docs/book/dist/firefly-rust-by-example-es.epub differ diff --git a/docs/book/dist/firefly-rust-by-example-es.pdf b/docs/book/dist/firefly-rust-by-example-es.pdf index 11135f6..3a10561 100644 Binary files a/docs/book/dist/firefly-rust-by-example-es.pdf and b/docs/book/dist/firefly-rust-by-example-es.pdf differ diff --git a/docs/book/dist/firefly-rust-by-example.epub b/docs/book/dist/firefly-rust-by-example.epub index ab1aed5..c785a2c 100644 Binary files a/docs/book/dist/firefly-rust-by-example.epub and b/docs/book/dist/firefly-rust-by-example.epub differ diff --git a/docs/book/dist/firefly-rust-by-example.pdf b/docs/book/dist/firefly-rust-by-example.pdf index b18e951..4757d86 100644 Binary files a/docs/book/dist/firefly-rust-by-example.pdf and b/docs/book/dist/firefly-rust-by-example.pdf differ diff --git a/docs/book/src-es/14a-spring-security-parity.md b/docs/book/src-es/14a-spring-security-parity.md index 63cb501..07571e8 100644 --- a/docs/book/src-es/14a-spring-security-parity.md +++ b/docs/book/src-es/14a-spring-security-parity.md @@ -13,27 +13,36 @@ comportamiento de Spring, sea cual sea su forma. ## Cobertura de un vistazo +En la columna **Estado**, :status-supported: indica una función soportada, +:status-partial: un módulo soportado pero opcional (activable por *feature*), y +:status-planned: un elemento de la hoja de ruta. + | Área | Estado | Notas | |------|--------|-------| -| Autorización de peticiones HTTP (`FilterChain`, RBAC, jerarquía de roles) | ✅ | Coincidencia por segmentos de ruta, denegar por defecto, gana la primera regla | -| Servidor de recursos Bearer / OAuth2 (JWT) | ✅ | JWKS con RSA + **EC (ES256/384)** + **EdDSA**; validación de `iss`/`aud`/`exp`/`nbf`; tolerancia de reloj de 60 s; *challenge* `WWW-Authenticate` (RFC 6750) | -| JWT simétrico (`JwtService`) | ✅ | HS256/384/512, `exp` obligatorio, tolerancia de reloj | -| Seguridad de método (`#[pre_authorize]` / `#[post_authorize]`) | ✅ | Funciona igual con autenticación **bearer *y* de sesión/OAuth2-login** | -| Comprobación de roles (`hasRole`) | ✅ | Acepta el prefijo `ROLE_` de Spring *y* nombres de rol sin prefijo | -| CORS | ✅ | Rechaza la combinación insegura de origen comodín + credenciales | -| Cabeceras de respuesta de seguridad | ✅ | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS solo en peticiones seguras** por defecto | -| CSRF (cookie de doble envío) | ✅ | El atributo `Secure` sigue el esquema de la petición; *bypass* para Bearer | -| Gestión de sesiones | ✅ | Rotación anti-fijación, control de concurrencia, registros distribuidos (Redis / **Postgres, con purga por TTL** / Mongo) | -| Codificación de contraseñas | ✅ | BCrypt + Argon2id; login en tiempo constante (sin oráculo temporal de enumeración de usuarios) | -| Login OAuth2 / OIDC | ✅ | Código de autorización + PKCE + state/nonce; **el `id_token` siempre se valida** (nunca se omite en silencio) | -| Login con token de un solo uso (enlace mágico) | ✅ | `oneTimeTokenLogin()` de Spring 6.4 — `OneTimeTokenService` + manejador de entrega + `/ott/generate` + `/login/ott` | -| WebAuthn / passkeys | 🧩 | `webAuthn()` de Spring 6.4 — módulo `webauthn` opcional (ceremonias de registro y autenticación) | -| Adaptadores de IdP | ✅ | Internal-DB, Keycloak, Azure AD / Entra, AWS Cognito | -| Arquitectura de autenticación | ✅ | `AuthenticationManager`/`ProviderManager`/`AuthenticationProvider`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `AuthenticationEventPublisher`, `AuthenticationEntryPoint`/`AccessDeniedHandler` conectables | -| Codificador de contraseñas delegado (migración `{id}`) | ✅ | `DelegatingPasswordEncoder` (`{bcrypt}`/`{argon2}`/`{noop}`) con re-hash en login (`upgrade_encoding`) | -| Form login / HTTP Basic / remember-me | 🚧 | Hoja de ruta | -| Cliente OAuth2 (`AuthorizedClientManager`) / Servidor de autorización | 🚧 | Lado de login presente; cliente saliente y servidor de autorización montado en la hoja de ruta | -| ACL / seguridad de objetos de dominio · SAML2 · LDAP/AD | 🚧 | Hoja de ruta (crates opcionales) | +| Autorización de peticiones HTTP (`FilterChain`, RBAC, jerarquía de roles) | :status-supported: | Coincidencia por segmentos de ruta, denegar por defecto, gana la primera regla | +| Servidor de recursos Bearer / OAuth2 (JWT) | :status-supported: | JWKS con RSA + **EC (ES256/384)** + **EdDSA**; validación de `iss`/`aud`/`exp`/`nbf`; tolerancia de reloj de 60 s; *challenge* `WWW-Authenticate` (RFC 6750) | +| JWT simétrico (`JwtService`) | :status-supported: | HS256/384/512, `exp` obligatorio, tolerancia de reloj | +| Seguridad de método (`#[pre_authorize]` / `#[post_authorize]`) | :status-supported: | Funciona igual con autenticación **bearer *y* de sesión/OAuth2-login** | +| Comprobación de roles (`hasRole`) | :status-supported: | Acepta el prefijo `ROLE_` de Spring *y* nombres de rol sin prefijo | +| CORS | :status-supported: | Rechaza la combinación insegura de origen comodín + credenciales | +| Cabeceras de respuesta de seguridad | :status-supported: | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS solo en peticiones seguras** por defecto | +| CSRF (cookie de doble envío) | :status-supported: | El atributo `Secure` sigue el esquema de la petición; *bypass* para Bearer | +| Gestión de sesiones | :status-supported: | Rotación anti-fijación, control de concurrencia, registros distribuidos (Redis / **Postgres, con purga por TTL** / Mongo) | +| Codificación de contraseñas | :status-supported: | BCrypt + Argon2id; login en tiempo constante (sin oráculo temporal de enumeración de usuarios) | +| Login OAuth2 / OIDC | :status-supported: | Código de autorización + PKCE + state/nonce; **el `id_token` siempre se valida** (nunca se omite en silencio) | +| Login con token de un solo uso (enlace mágico) | :status-supported: | `oneTimeTokenLogin()` de Spring 6.4 — `OneTimeTokenService` + manejador de entrega + `/ott/generate` + `/login/ott` | +| WebAuthn / passkeys | :status-partial: | `webAuthn()` de Spring 6.4 — módulo `webauthn` opcional (ceremonias de registro y autenticación) | +| Adaptadores de IdP | :status-supported: | Internal-DB, Keycloak, Azure AD / Entra, AWS Cognito | +| Arquitectura de autenticación | :status-supported: | `AuthenticationManager`/`ProviderManager`/`AuthenticationProvider`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `AuthenticationEventPublisher`, `AuthenticationEntryPoint`/`AccessDeniedHandler` conectables | +| Codificador de contraseñas delegado (migración `{id}`) | :status-supported: | `DelegatingPasswordEncoder` (`{bcrypt}`/`{argon2}`/`{noop}`) con re-hash en login (`upgrade_encoding`) | +| HTTP Basic (`httpBasic()`) | :status-supported: | `HttpBasicLayer` sobre la columna de autenticación; cabecera ausente pasa de largo, inválida/malformada → `401` + `WWW-Authenticate: Basic realm=…` | +| Form login (`formLogin()`) | :status-supported: | `form_login_routes` (`POST /login`), rotación del id de sesión (anti-fijación), manejadores de éxito/fallo conectables, redirección consciente de la petición guardada | +| Remember-me (`rememberMe()`) | :status-supported: | `TokenBasedRememberMeServices` — token firmado, con caducidad y ligado al hash de la contraseña; niveles de confianza `is_remembered()` / `is_fully_authenticated()` | +| `RequestCache` / `SavedRequest` | :status-supported: | `HttpSessionRequestCache` — la página previa al login se restaura tras autenticarse (solo redirección del mismo origen) | +| `SessionCreationPolicy` | :status-supported: | `Always`/`IfRequired`/`Never`/`Stateless`; `Stateless` instala el repositorio de contexto nulo para APIs de tokens | +| Múltiples cadenas de filtros | :status-supported: | `SecurityFilterChains` — gana el primer `RequestMatcher` que coincide (el `FilterChainProxy` de Spring) | +| Cliente OAuth2 (`AuthorizedClientManager`) / Servidor de autorización | :status-planned: | Lado de login presente; cliente saliente y servidor de autorización montado en la hoja de ruta | +| ACL / seguridad de objetos de dominio · SAML2 · LDAP/AD | :status-planned: | Hoja de ruta (crates opcionales) | ## Comportamientos fieles a Spring que conviene conocer @@ -62,6 +71,45 @@ un port ingenuo — cada uno tiene una vía de escape por configuración: - **El login con usuario desconocido consume un tiempo de bcrypt comparable** al de una contraseña incorrecta, cerrando el oráculo temporal de enumeración. +## Form login, HTTP Basic y remember-me + +Los mecanismos clásicos de autenticación web, fieles a los valores por defecto +de Spring: + +- **HTTP Basic** — `HttpBasicLayer::new(manager)` lee `Authorization: Basic …` y + autentica mediante el `AuthenticationManager` del Nivel 1. Una cabecera + **ausente** pasa de largo (para que una capa de sesión o bearer tome el + relevo); una **inválida o malformada** se rechaza con `401` y un *challenge* + `WWW-Authenticate: Basic realm="…"` — el `BasicAuthenticationFilter` de Spring. +- **Form login** — `form_login_routes(state)` monta `POST /login` + (`username` + `password` codificados como formulario), rota el id de sesión al + tener éxito (anti-fijación) **antes** de persistir el contexto, y luego + redirige. Las respuestas de éxito/fallo son intercambiables + (`FormLoginSuccessHandler` / `FormLoginFailureHandler`) y el camino de éxito es + consciente de la petición guardada. +- **Remember-me** — `TokenBasedRememberMeServices` acuña un token de cookie + firmado y con caducidad, ligado al hash de la contraseña del usuario y a una + clave del servidor (el `TokenBasedRememberMeServices` de Spring): un cambio de + contraseña, un reloj más allá de la caducidad, un token manipulado o una clave + incorrecta lo rechazan. Un contexto recordado está *autenticado pero no + totalmente autenticado* — `is_remembered()` es `true` e + `is_fully_authenticated()` es `false`, de modo que una ruta sensible puede + exigir un login fresco (`isFullyAuthenticated()` de Spring). +- **Caché de peticiones** — cuando el *entry point* envía a un usuario no + autenticado a iniciar sesión, `HttpSessionRequestCache` recuerda la página que + quería; el form login lo devuelve allí en lugar del destino por defecto (el + `SavedRequestAwareAuthenticationSuccessHandler` de Spring). Solo se respetan + destinos del **mismo origen** — una ruta guardada se rechaza si pudiera + redirigir fuera del sitio. +- **Política de creación de sesión** — `SessionCreationPolicy::{Always, + IfRequired, Never, Stateless}` elige si la capa de seguridad persiste su + contexto en la sesión; `Stateless` (APIs de tokens) instala el repositorio de + contexto nulo. +- **Múltiples cadenas de filtros** — `SecurityFilterChains` enruta cada petición + a la primera cadena cuyo `RequestMatcher` (p. ej. + `PathRequestMatcher::new("/api")`) coincide, de modo que un `/api/**` blindado + y una superficie web permisiva coexisten — el `FilterChainProxy` de Spring. + ## Login sin contraseña Firefly incluye los dos mecanismos sin contraseña de Spring Security 6.4: @@ -86,8 +134,9 @@ La paridad se entrega por niveles, cada uno un incremento: `ProviderManager`, `DaoAuthenticationProvider` + `UserDetails`, `SecurityContextRepository`, `DelegatingPasswordEncoder`, eventos de autenticación, manejadores conectables de entry-point / access-denied. -3. **Mecanismos web** — form login, HTTP Basic, remember-me, `RequestCache`, - `SessionCreationPolicy`, múltiples cadenas de filtros. +3. **Mecanismos web (hecho)** — form login, HTTP Basic, remember-me, + `RequestCache` / `SavedRequest`, `SessionCreationPolicy`, múltiples cadenas de + filtros. 4. **Profundidad de seguridad de método** — enlace de argumentos/principal estilo SpEL, `@PreFilter`/`@PostFilter`, `PermissionEvaluator`. 5. **Ecosistema OAuth2** — introspección de tokens opacos, gestor de clientes diff --git a/docs/book/src/14a-spring-security-parity.md b/docs/book/src/14a-spring-security-parity.md index be75bf1..01ece10 100644 --- a/docs/book/src/14a-spring-security-parity.md +++ b/docs/book/src/14a-spring-security-parity.md @@ -12,27 +12,36 @@ servlet filters, traits instead of interfaces, builder functions instead of the ## Coverage at a glance +In the **Status** column, :status-supported: marks a supported feature, +:status-partial: a supported but opt-in (feature-gated) module, and +:status-planned: a roadmap item. + | Area | Status | Notes | |------|--------|-------| -| HTTP request authorization (`FilterChain`, RBAC, role hierarchy) | ✅ | Path-segment-aware matching, deny-by-default, first-match-wins | -| Bearer / OAuth2 resource server (JWT) | ✅ | JWKS with RSA + **EC (ES256/384)** + **EdDSA**; `iss`/`aud`/`exp`/`nbf` validation; 60 s clock-skew leeway; RFC 6750 `WWW-Authenticate` challenge | -| Symmetric JWT (`JwtService`) | ✅ | HS256/384/512, `exp` required, clock-skew leeway | -| Method security (`#[pre_authorize]` / `#[post_authorize]`) | ✅ | Works uniformly across **bearer *and* session/OAuth2-login** auth | -| Role checks (`hasRole`) | ✅ | Accepts Spring's `ROLE_` prefix *and* bare role names | -| CORS | ✅ | Rejects the unsafe wildcard-origin + credentials combination | -| Security response headers | ✅ | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS is secure-request-only** by default | -| CSRF (double-submit cookie) | ✅ | `Secure` cookie follows the request scheme; Bearer bypass | -| Session management | ✅ | Fixation rotation, concurrency control, distributed registries (Redis / **Postgres, with TTL pruning** / Mongo) | -| Password encoding | ✅ | BCrypt + Argon2id; constant-time login (no user-enumeration timing oracle) | -| OAuth2 / OIDC login | ✅ | Auth-code + PKCE + state/nonce; **`id_token` is always validated** (never silently skipped) | -| One-time-token login (magic link) | ✅ | Spring 6.4 `oneTimeTokenLogin()` — `OneTimeTokenService` + delivery handler + `/ott/generate` + `/login/ott` | -| WebAuthn / passkeys | 🧩 | Spring 6.4 `webAuthn()` — feature-gated `webauthn` module (registration + authentication ceremonies) | -| IdP adapters | ✅ | Internal-DB, Keycloak, Azure AD / Entra, AWS Cognito | -| Authentication architecture | ✅ | `AuthenticationManager`/`ProviderManager`/`AuthenticationProvider`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `AuthenticationEventPublisher`, pluggable `AuthenticationEntryPoint`/`AccessDeniedHandler` | -| Delegating password encoder (`{id}` migration) | ✅ | `DelegatingPasswordEncoder` (`{bcrypt}`/`{argon2}`/`{noop}`) with `upgrade_encoding` re-hash-on-login | -| Form login / HTTP Basic / remember-me | 🚧 | Roadmap | -| OAuth2 client (`AuthorizedClientManager`) / Authorization Server | 🚧 | Login side present; outbound client + a mounted authorization server on the roadmap | -| ACL / domain-object security · SAML2 · LDAP/AD | 🚧 | Roadmap (opt-in crates) | +| HTTP request authorization (`FilterChain`, RBAC, role hierarchy) | :status-supported: | Path-segment-aware matching, deny-by-default, first-match-wins | +| Bearer / OAuth2 resource server (JWT) | :status-supported: | JWKS with RSA + **EC (ES256/384)** + **EdDSA**; `iss`/`aud`/`exp`/`nbf` validation; 60 s clock-skew leeway; RFC 6750 `WWW-Authenticate` challenge | +| Symmetric JWT (`JwtService`) | :status-supported: | HS256/384/512, `exp` required, clock-skew leeway | +| Method security (`#[pre_authorize]` / `#[post_authorize]`) | :status-supported: | Works uniformly across **bearer *and* session/OAuth2-login** auth | +| Role checks (`hasRole`) | :status-supported: | Accepts Spring's `ROLE_` prefix *and* bare role names | +| CORS | :status-supported: | Rejects the unsafe wildcard-origin + credentials combination | +| Security response headers | :status-supported: | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS is secure-request-only** by default | +| CSRF (double-submit cookie) | :status-supported: | `Secure` cookie follows the request scheme; Bearer bypass | +| Session management | :status-supported: | Fixation rotation, concurrency control, distributed registries (Redis / **Postgres, with TTL pruning** / Mongo) | +| Password encoding | :status-supported: | BCrypt + Argon2id; constant-time login (no user-enumeration timing oracle) | +| OAuth2 / OIDC login | :status-supported: | Auth-code + PKCE + state/nonce; **`id_token` is always validated** (never silently skipped) | +| One-time-token login (magic link) | :status-supported: | Spring 6.4 `oneTimeTokenLogin()` — `OneTimeTokenService` + delivery handler + `/ott/generate` + `/login/ott` | +| WebAuthn / passkeys | :status-partial: | Spring 6.4 `webAuthn()` — feature-gated `webauthn` module (registration + authentication ceremonies) | +| IdP adapters | :status-supported: | Internal-DB, Keycloak, Azure AD / Entra, AWS Cognito | +| Authentication architecture | :status-supported: | `AuthenticationManager`/`ProviderManager`/`AuthenticationProvider`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `AuthenticationEventPublisher`, pluggable `AuthenticationEntryPoint`/`AccessDeniedHandler` | +| Delegating password encoder (`{id}` migration) | :status-supported: | `DelegatingPasswordEncoder` (`{bcrypt}`/`{argon2}`/`{noop}`) with `upgrade_encoding` re-hash-on-login | +| HTTP Basic (`httpBasic()`) | :status-supported: | `HttpBasicLayer` over the auth spine; absent header passes through, invalid/malformed → `401` + `WWW-Authenticate: Basic realm=…` | +| Form login (`formLogin()`) | :status-supported: | `form_login_routes` (`POST /login`), session-id rotation (anti-fixation), pluggable success/failure handlers, saved-request-aware redirect | +| Remember-me (`rememberMe()`) | :status-supported: | `TokenBasedRememberMeServices` — signed, expiring, password-hash-bound token; `is_remembered()` / `is_fully_authenticated()` trust levels | +| `RequestCache` / `SavedRequest` | :status-supported: | `HttpSessionRequestCache` — the pre-login page restored after authentication (same-origin redirect only) | +| `SessionCreationPolicy` | :status-supported: | `Always`/`IfRequired`/`Never`/`Stateless`; `Stateless` installs the null context repository for token APIs | +| Multiple filter chains | :status-supported: | `SecurityFilterChains` — first matching `RequestMatcher` wins (Spring's `FilterChainProxy`) | +| OAuth2 client (`AuthorizedClientManager`) / Authorization Server | :status-planned: | Login side present; outbound client + a mounted authorization server on the roadmap | +| ACL / domain-object security · SAML2 · LDAP/AD | :status-planned: | Roadmap (opt-in crates) | ## Spring-faithful behaviours to know @@ -60,6 +69,41 @@ has a configuration escape hatch: - **Unknown-username login spends comparable bcrypt time** to a wrong password, closing the user-enumeration timing oracle. +## Form login, HTTP Basic, and remember-me + +The classic web authentication mechanisms, faithful to Spring's defaults: + +- **HTTP Basic** — `HttpBasicLayer::new(manager)` reads + `Authorization: Basic …` and authenticates through the Tier 1 + `AuthenticationManager`. An **absent** header passes through (so a session or + bearer layer can take over); an **invalid or malformed** one is rejected with + `401` and a `WWW-Authenticate: Basic realm="…"` challenge — Spring's + `BasicAuthenticationFilter`. +- **Form login** — `form_login_routes(state)` mounts `POST /login` + (url-encoded `username` + `password`), rotates the session id on success + (anti-fixation) **before** persisting the context, then redirects. The + success/failure responses are swappable (`FormLoginSuccessHandler` / + `FormLoginFailureHandler`), and the success path is **saved-request-aware**. +- **Remember-me** — `TokenBasedRememberMeServices` mints a signed, expiring + cookie token bound to the user's stored password hash and a server key + (Spring's `TokenBasedRememberMeServices`): a password change, a clock past the + expiry, a tampered token, or the wrong key all reject. A remembered context is + *authenticated but not fully authenticated* — `is_remembered()` is `true` and + `is_fully_authenticated()` is `false`, so a sensitive route can demand a fresh + login (Spring's `isFullyAuthenticated()`). +- **Request cache** — when the entry point sends an unauthenticated user to log + in, `HttpSessionRequestCache` remembers the page they wanted; form login then + returns them there instead of the default target (Spring's + `SavedRequestAwareAuthenticationSuccessHandler`). Only **same-origin** targets + are honoured — a saved path is rejected if it could redirect off-site. +- **Session creation policy** — `SessionCreationPolicy::{Always, IfRequired, + Never, Stateless}` chooses whether the security tier persists its context in + the session; `Stateless` (token APIs) installs the null context repository. +- **Multiple filter chains** — `SecurityFilterChains` routes each request to the + first chain whose `RequestMatcher` (e.g. `PathRequestMatcher::new("/api")`) + matches, so a locked-down `/api/**` and a permissive web surface coexist — + Spring's `FilterChainProxy`. + ## Passwordless login Firefly ships the two Spring Security 6.4 passwordless mechanisms: @@ -83,8 +127,9 @@ Parity is delivered in tiers, each its own increment: `DaoAuthenticationProvider` + `UserDetails`, `SecurityContextRepository`, `DelegatingPasswordEncoder`, authentication events, pluggable entry-point / access-denied handlers. -3. **Web mechanisms** — form login, HTTP Basic, remember-me, `RequestCache`, - `SessionCreationPolicy`, multiple filter chains. +3. **Web mechanisms (done)** — form login, HTTP Basic, remember-me, + `RequestCache` / `SavedRequest`, `SessionCreationPolicy`, multiple filter + chains. 4. **Method-security depth** — SpEL-style argument/principal binding, `@PreFilter`/`@PostFilter`, `PermissionEvaluator`. 5. **OAuth2 ecosystem** — opaque-token introspection, the outbound diff --git a/docs/book/theme/book.css b/docs/book/theme/book.css index 16d5a33..5c0cc27 100644 --- a/docs/book/theme/book.css +++ b/docs/book/theme/book.css @@ -99,6 +99,8 @@ td code,th code{ background:#f3ecdf; } margin:0 0 .32rem; display:flex; align-items:center; gap:.42rem; font-family:"Avenir Next",Avenir,Helvetica,Arial,sans-serif; } .adm-ico{ flex:none; width:15px; height:15px; } +/* Status icons in capability/coverage matrices (see md.py _STATUS_ICON). */ +.status-ico{ width:16px; height:16px; vertical-align:-0.18em; } .admonition.note{ background:var(--note-bg); border-color:#cfe0fb; border-left-color:var(--note); } .admonition.note>.admonition-title{ color:var(--note); } .admonition.tip{ background:var(--tip-bg); border-color:#cdeccd; border-left-color:var(--tip); }