diff --git a/.env.local b/.env.local index 1260d0f08..f9bc3983b 100644 --- a/.env.local +++ b/.env.local @@ -1,6 +1,5 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc TORRUST_INDEX_CONFIG_TOML= -TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY=MaxVerstappenWC2021 USER_ID=1000 TORRUST_TRACKER_CONFIG_TOML= TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=Sqlite3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 639fc3edc..be6373e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 188 crate-level tests for the domain error system (`src/tests/errors/`): status-code mapping, display messages, `From` impl coverage, and `ApiError` delegation (ADR-T-006 §1–§4). +- ADR-T-007: Document rationale for JWT system refactor. +- Centralised JWT module (`src/jwt.rs`) consolidating all `jsonwebtoken` usage: + key loading, signing, verification, and algorithm configuration. +- `SessionClaims` with RFC 7519 registered claims (`sub`, `iss`, `aud`, `iat`, + `exp`) plus advisory `role`, `username`, and revocation `gen` fields. +- `VerifyClaims` with `aud: "email-verification"` for purpose separation. +- RSA key pair configuration: `auth.private_key_path` / `auth.public_key_path` + (or inline PEM via `auth.private_key_pem` / `auth.public_key_pem`). +- Ephemeral auto-generated RSA-2048 key pair when no keys are configured. + Sessions do not survive server restarts with ephemeral keys. Deployers who + want persistent sessions supply their own key pair via config. +- `torrust-generate-auth-keypair` CLI binary for generating RSA-2048 key pairs. + Outputs both PEM blocks to stdout; refuses to run if stdout is a terminal. +- Container auto-generation of persistent auth keys on first boot. The entry + script runs `torrust-generate-auth-keypair` and writes the PEM files to + `/etc/torrust/index/auth/` on the volume. Sessions survive restarts with no + manual setup. +- `kid` (Key ID) header in every JWT for future key rotation support. +- Configurable token lifetimes: `auth.session_token_lifetime_secs` (default: + 2 weeks) and `auth.email_verification_token_lifetime_secs` (default: ~10 years). +- `token_generation` column on `torrust_users` (migration for SQLite and MySQL). +- Token revocation: password changes, role changes (admin grant), and bans + increment `token_generation`; tokens with an older `gen` claim are rejected. +- Consolidated session validation: `JsonWebToken::validate_session` is the + sole entry point for verifying a session JWT, checking the token-generation + counter, and rejecting banned users. All callers delegate here. +- `BearerToken` extractor rejects missing/malformed `Authorization` headers at + the extraction boundary (`AuthError::TokenNotFound` / `AuthError::TokenInvalid`). +- `ExtractOptionalLoggedInUser` catches extraction rejection and returns `None` + for anonymous requests. +- `AuthError::TokenRevoked` variant for revoked-token responses. +- Crate tests for the JWT module (session + email-verification round-trips, + audience cross-contamination, tampered/garbage tokens). +- Crate tests for `parse_token` (valid extraction, whitespace trimming, + empty bearer, missing prefix, non-ASCII rejection). ### Changed +- **BREAKING:** JWT signing algorithm changed from HMAC-HS256 to RS256 + (RSA + SHA-256). Existing HS256 tokens are invalidated; users must re-login. +- **BREAKING:** JWT claims redesigned from `UserClaims { user, exp }` to + `SessionClaims { sub, iss, aud, iat, exp, role, username, gen }`. Existing + tokens without the new claims fail deserialization. +- **BREAKING:** Configuration keys changed — `auth.user_claim_token_pepper` / + `auth.session_signing_key` / `auth.email_verification_signing_key` replaced + by `auth.private_key_path` and `auth.public_key_path` (or inline PEM). + Deployers must generate an RSA key pair. - **BREAKING:** Replace `ServiceError` (41 variants) and `ServiceResult` with domain-scoped error enums: `AuthError`, `UserError`, `TorrentError`, `CategoryTagError`, and a thin `ApiError` wrapper (ADR-T-006). +- `Authentication::get_user_id_from_bearer_token` now takes `BearerToken` + directly instead of `Option`. +- `ExtractLoggedInUser` and `ExtractOptionalLoggedInUser` use `BearerToken` + directly instead of the old `Extract` wrapper. +- `parse_token` returns `Result` instead of panicking on malformed headers. +- JWT `exp` validation relies solely on the `jsonwebtoken` library; redundant + manual expiration check removed. +- Token signing uses `Result` propagation instead of `.unwrap()` / `.expect()`. +- `UserClaims` is now a type alias for `SessionClaims` (backward-compatible). +- `VerifyClaims` moved from `mailer` into the `jwt` module (re-exported for + backward compatibility). - Service functions now return domain-specific `Result` instead of `Result`. - Each domain error co-locates its HTTP status-code mapping via a @@ -28,6 +83,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- `bearer_token::Extract` wrapper struct (replaced by `BearerToken` directly). +- `get_optional_logged_in_user` free function (logic moved into extractors). +- `get_claims_from_bearer_token` private method on `Authentication` (inlined). +- `ClaimTokenPepper` / `JwtSigningSecret` / `user_claim_token_pepper` config + keys (replaced by RSA key pair configuration). - `ServiceError` enum and `ServiceResult` type alias from `src/errors.rs`. - `http_status_code_for_service_error` and `map_database_error_to_service_error` helper functions. diff --git a/Cargo.lock b/Cargo.lock index e38391653..2b4d3ea36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4274,6 +4274,7 @@ dependencies = [ "rand 0.10.0", "regex", "reqwest", + "rsa", "serde", "serde_bencode", "serde_bytes", @@ -4281,6 +4282,7 @@ dependencies = [ "serde_json", "serde_with", "sha-1", + "sha2", "sqlx", "tempfile", "tera", diff --git a/Cargo.toml b/Cargo.toml index d6eaeaeeb..0e80dcf6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ pbkdf2 = { version = "0", features = ["simple"] } pin-project-lite = "0" rand = "0.10" regex = "1" +rsa = { version = "0.9", default-features = false, features = ["std", "pem"] } reqwest = { version = "0", features = ["json", "multipart", "query"] } serde = { version = "1", features = ["derive", "rc"] } serde_bencode = "0" @@ -88,6 +89,7 @@ serde_derive = "1" serde_json = "1" serde_with = "3" sha-1 = "0" +sha2 = "0" sqlx = { version = "0", features = ["migrate", "mysql", "runtime-tokio-native-tls", "sqlite", "time"] } tera = { version = "1", default-features = false } text-colorizer = "1" @@ -102,6 +104,10 @@ url = { version = "2", features = ["serde"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } +[[bin]] +name = "torrust-generate-auth-keypair" +path = "src/bin/generate_auth_keypair.rs" + [dev-dependencies] tempfile = "3" which = "8" diff --git a/Containerfile b/Containerfile index b17dd7ea6..9ded73d01 100644 --- a/Containerfile +++ b/Containerfile @@ -71,7 +71,9 @@ COPY --from=build_debug \ RUN cargo nextest run --workspace-remap /test/src/ --extract-to /test/src/ --no-run --archive-file /test/torrust-index-debug.tar.zst RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/target/ --cargo-metadata /test/src/target/nextest/cargo-metadata.json --binaries-metadata /test/src/target/nextest/binaries-metadata.json -RUN mkdir -p /app/bin/; cp -l /test/src/target/debug/torrust-index /app/bin/torrust-index +RUN mkdir -p /app/bin/; \ + cp -l /test/src/target/debug/torrust-index /app/bin/torrust-index; \ + cp -l /test/src/target/debug/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair # RUN mkdir /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-index | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1 RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin @@ -87,7 +89,8 @@ RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/ RUN mkdir -p /app/bin/; \ cp -l /test/src/target/release/torrust-index /app/bin/torrust-index; \ - cp -l /test/src/target/release/health_check /app/bin/health_check; + cp -l /test/src/target/release/health_check /app/bin/health_check; \ + cp -l /test/src/target/release/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair # RUN mkdir -p /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-index | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1 RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin diff --git a/README.md b/README.md index 27e4e9c4a..f967295a5 100644 --- a/README.md +++ b/README.md @@ -93,19 +93,34 @@ _Optionally, you may choose to supply the entire configuration as an environment TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") cargo run ``` -_For deployment, you __should__ override: - -- The `tracker_api_token` and the `index_auth_secret_key` by using environmental variables:_ +_For deployment, you __should__ override the `tracker_api_token`:_ ```sh -# Please use the secret that you generated for the torrust-tracker configuration. -# Override secret in configuration using an environmental variable +# Override secrets in configuration using environmental variables TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") \ TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=$(cat "./storage/tracker/lib/tracker_api_admin_token.secret") \ - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY="MaxVerstappenWC2021" \ cargo run ``` +_By default, an ephemeral RSA key pair is auto-generated in memory for JWT +signing. Sessions will not survive server restarts. For **persistent sessions**, +generate your own RSA key pair and configure the paths:_ + +```sh +# Generate an RSA key pair for JWT signing: +openssl genrsa -out private.pem 2048 +openssl rsa -in private.pem -pubout -out public.pem + +# Supply key paths via environment variables: +TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH="/path/to/private.pem" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/path/to/public.pem" \ + cargo run +``` + +> **Container deployments** auto-generate persistent keys on first boot — no +> manual setup is required. See the [container guide][containers.md] for details. + > Please view our [crate documentation][docs] for more detailed instructions. ### Services @@ -127,6 +142,7 @@ The following services are provided by the default configuration: - [ADR-T-004: Remove `located-error` Package](adr/004-remove-located-error.md) — Replace the `torrust-index-located-error` wrapper with `tracing` for error context. - [ADR-T-005: Migrate to Rust Edition 2024](adr/005-edition-2024.md) — Migrate the entire workspace to `edition = "2024"` and raise the MSRV to 1.85. - [ADR-T-006: Refactor the Error System](adr/006-error-system-refactor.md) — Replace the 41-variant `ServiceError` god enum with domain-scoped error enums (`AuthError`, `UserError`, `TorrentError`, `CategoryTagError`) and a thin `ApiError` wrapper. +- [ADR-T-007: Refactor the JWT System](adr/007-jwt-system-refactor.md) — Centralise JWT handling into `src/jwt.rs`, redesign claims to RFC 7519, move to RS256 asymmetric signing, and consolidate session validation into a single code path. ## Contributing diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md new file mode 100644 index 000000000..d5d538c84 --- /dev/null +++ b/adr/007-jwt-system-refactor.md @@ -0,0 +1,508 @@ +# ADR-T-007: Refactor the JWT System + +**Status:** Phases 1–7 implemented +**Date:** 2026-04-14 +**Updated:** 2026-04-16 + +## Context + +JWT handling was spread across four locations with two distinct +claim types, sharing a single HMAC-HS256 signing secret: + +| Location | Purpose | Claim type | +|---|---|---| +| `services::authentication::JsonWebToken` | Sign/verify session tokens | `UserClaims` | +| `web::api::server::v1::auth::Authentication` | Wrapper delegating to `JsonWebToken` | `UserClaims` | +| `mailer::Service::get_verification_url` | Sign email-verification tokens | `VerifyClaims` | +| `services::user::RegistrationService::verify_email` | Verify email-verification tokens | `VerifyClaims` | + +### Problems + +1. **Single shared secret.** Session and email-verification JWTs + shared one HMAC key (`user_claim_token_pepper`). Compromising + one compromised both. + +2. **Low-entropy HMAC key.** A human-readable string (default: + `"MaxVerstappenWC2021"`) used directly as + `EncodingKey::from_secret`. No minimum entropy, no key + derivation, no asymmetric support. + +3. **Missing registered claims.** `UserClaims` had only + `{ user, exp }` — no `iss`, `aud`, `sub`, or `iat`. + `VerifyClaims` had `iss` and `sub` but not `aud`. + +4. **Stale privileges in tokens.** The `administrator` flag was + trusted from the token. Role changes were invisible until + token expiry (two weeks). + +5. **Hard-coded expiration.** Session: two weeks. + Email verification: ~10 years. Renewal threshold: one week. + None configurable. + +6. **Redundant expiration check.** Library validation and a + manual `exp` check could disagree on clock skew. + +7. **Panics on encode failure.** `.unwrap()` / `.expect()` on + `encode` in both signing paths. + +8. **Panics on malformed headers.** `.expect()` and blind + indexing in `parse_token`. + +9. **No revocation.** Once signed, tokens were valid until `exp`. + +10. **Scattered `jsonwebtoken` usage.** Imported in three files + with no centralised module. + +11. **Extractor swallowed missing headers.** `BearerToken` + returned `Ok(None)` on a missing `Authorization` header, + pushing the auth check into every handler. + +12. **Misleading naming.** `ClaimTokenPepper` used cryptographic + pepper terminology for what was an HMAC signing key. + +## Options Considered + +### A — Incremental Cleanup + +Centralise `jsonwebtoken`, fix panics, make durations +configurable, rename types. Small diff, no breaking change. + +Does not address stale roles (#4), single secret (#1), +revocation (#9), or low-entropy keys (#2). + +### B — Claim Redesign + Per-Purpose Keys + +Option A plus: per-purpose signing keys, RFC 7519 claims, +database-authoritative roles. Breaking change (re-login +required). + +### C — Asymmetric Signing (RS256) + +Option B plus: replace HS256 with RS256. Private key signs; +public key verifies. Strongest posture, highest complexity. + +### D — Opaque Tokens + Server-Side Store + +Replace JWTs entirely with random opaque tokens. Instant +revocation, no key management. Loses statelessness; requires a +store lookup on every request. + +### E — Hybrid (JWT + Generation Counter) + +Keep JWTs; add a per-user `token_generation` counter. Increment +on password/role/ban changes. Near-instant revocation with +minimal infrastructure. + +## Decision + +**Option C (RS256)** as a phased rollout that subsumes A, B, +and E. + +### Rationale + +- **Already supported.** `jsonwebtoken 10.3.0` with + `rust_crypto` enables `rsa`, `pem`, `sha2` — no new crates. +- **Strongest posture.** Asymmetric signing eliminates the + shared-secret problem; only the signing service holds the + private key. +- **Future-proof.** External services verify tokens with the + public key alone. JWKS / `kid` rotation can be added later. +- **RS256 over EdDSA.** Most widely supported JWT algorithm + (RFC 7518 §3.1); operationally well-understood key management. + EdDSA's smaller signatures are negligible for auth tokens. +- **Not Option D.** Opaque tokens require a mandatory store + lookup and session management infrastructure the project + doesn't need. + +## Implementation + +### Phase 1 — Structural Cleanup ✅ + +Extracted `src/jwt.rs` centralising all `jsonwebtoken` usage. +Moved claim types into the new module. Replaced panicking +`.unwrap()` / `.expect()` with `Result` propagation. Fixed +`parse_token` to return `Result`. Removed the redundant manual +`exp` check. Renamed `ClaimTokenPepper` → `JwtSigningSecret`. +Made expiration durations configurable: +`session_token_lifetime_secs`, +`email_verification_token_lifetime_secs`. + +### Phase 2 — Claim Redesign + Per-Purpose Keys ✅ + +Redesigned `UserClaims` → `SessionClaims` with RFC 7519 +registered claims (`sub`, `iss`, `aud`, `iat`, `exp`) plus +advisory `role` and `username` fields: + +```rust +struct SessionClaims { + sub: UserId, // subject = user ID + iss: String, // "torrust-index" + aud: String, // "session" + iat: u64, + exp: u64, + role: String, // advisory — non-authoritative + username: String, // advisory — non-authoritative + gen: u64, // token generation (added in Phase 4) +} +``` + +Redesigned `VerifyClaims` with `aud: "email-verification"`. +Split config into two independent signing keys. Role is +re-validated from the database on every authenticated request +(advisory only in token). + +**Breaking:** existing HS256 tokens invalidated. + +### Phase 3 — RS256 Asymmetric Signing ✅ + +Replaced HS256 with RS256. Config provides +`auth.private_key_path` / `auth.public_key_path` (or inline +PEM via env vars). Uses `EncodingKey::from_rsa_pem` / +`DecodingKey::from_rsa_pem`. Only the signing service loads the +private key. A `kid` (SHA-256 fingerprint of the public key) +is included in every JWT header for future key rotation. + +**Breaking:** HS256 config keys no longer supported. + +### Phase 4 — Token Revocation ✅ + +Added a `token_generation` column (default `0`) to +`torrust_users`. `SessionClaims` includes a `gen` field; tokens +whose `gen` does not **exactly** match the database value are +rejected (`!=`, not `<` — tokens are also rejected if the +database generation *decreases*, e.g. restore from backup). + +State-changing operations use atomic database methods so the +state change and generation bump either both apply or neither +does: + +| Operation | Method | Mechanism | +|---|---|---| +| Password change | `change_user_password_and_revoke_tokens` | Transaction | +| Admin grant | `grant_admin_role_and_revoke_tokens` | Single UPDATE | +| Ban | `ban_user_and_revoke_tokens` | Transaction | + +The ban table is also checked as a secondary guard: if +`token_generation` somehow matches despite an active ban, +`is_user_banned` catches it. + +Validation is consolidated into `JsonWebToken::validate_session` +(Phase 7). + +**Breaking:** tokens without a `gen` claim fail deserialization. + +### Phase 5 — Ephemeral Auto-Generated Keys ✅ + +When no key paths or PEM values are configured, an RSA-2048 key +pair is auto-generated in memory at startup via +`RsaPrivateKey::new(&mut OsRng, 2048)` (wrapped in +`spawn_blocking`). Keys are never written to disk; sessions do +not survive restarts. For persistent sessions, the deployer +supplies their own key pair. + +Removed shipped development keys. `Auth::default()` sets key +paths to `None`. The `resolve_*` methods return +`Option>` so callers distinguish "no key configured" +from "invalid key." + +#### Dependencies + +The `rsa` crate (already transitive via `jsonwebtoken`) is a +direct dependency along with `rand`. PEM export uses +`EncodePrivateKey::to_pkcs8_pem` and +`EncodePublicKey::to_public_key_pem` from transitive `pkcs8` +and `spki`. + +### Phase 6 — `generate-auth-keypair` CLI ✅ + +A binary `torrust-generate-auth-keypair` +(`src/bin/generate_auth_keypair.rs`) generates an RSA-2048 key +pair to stdout. Design: + +- Refuses to run if stdout is a terminal. +- Private key first, then public key — self-delimiting PEM. +- Diagnostics on stderr via `tracing`; `--debug` for verbose. +- Uses `clap` for CLI. + +#### Container integration + +The entry script (`share/container/entry_script_sh`) +auto-generates persistent keys on first boot into +`/etc/torrust/index/auth/`. Hardening: + +- `mkdir -p` with `0700` before any key material is written. +- `mktemp` + `chmod 0600` for the intermediate file. +- `[ ! -s … ]` (existence + non-empty) guards against + zero-byte files from interrupted prior runs. +- `trap … EXIT` ensures temp file cleanup. +- `sed` matches exact PEM markers (PKCS#8 / SPKI). +- Errors on stderr (visible in `docker logs`); non-zero exit + on failure. +- **TOCTOU note:** if two containers race against the same + volume, both could pass the check and overwrite each other's + keys. Mitigate with `flock` if needed; single-container + deployments are the norm. + +Container configs set `auth.private_key_path` / +`auth.public_key_path` to the generated paths. Sessions persist +via the `/etc/torrust/index` volume. + +#### Containerfile + +`torrust-generate-auth-keypair` is copied into `/usr/bin/` in +both the debug and release runtime images alongside +`torrust-index` and `health_check`. + +#### Host-supplied keys + +Two workflows: + +1. **Pre-supply:** mount or copy keys to the volume before first + boot. The `[ ! -s … ]` check skips generation. + +2. **Overwrite:** let the container auto-generate on first boot, + then replace the PEM files and restart. + +#### Usage outside containers + +```sh +tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) +chmod 0600 "$tmpfile" +cargo run --bin torrust-generate-auth-keypair > "$tmpfile" +sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > private.pem +sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > public.pem +rm -f "$tmpfile" +``` + +> **Avoid** the Bash process-substitution form +> (`tee >(sed …) >(sed …)`). The `>(…)` sub-processes run +> asynchronously, so the `sed` writes may not have flushed +> when the pipeline exits — producing truncated PEM files. +> The POSIX version above is strictly correct. + +### Phase 7 — Consolidate Session Validation ✅ + +#### Problem + +Phase 4's validation logic — verify JWT, check generation, check +ban — is copy-pasted at three call sites: + +| Entry point | Location | +|---|---| +| `Authentication::get_user_id_from_bearer_token` | `web::api::server::v1::auth` | +| `verify_token_handler` | `web::api::server::v1::contexts::user::handlers` | +| `Service::renew_token` | `services::authentication` | + +Each site independently re-implements the same sequence: verify +JWT → fetch `token_generation` → compare `gen` → check ban +table. This was originally framed as "defence in depth," but all +three sites are at the same architectural layer performing +identical checks. The duplication is a maintenance hazard, not a +safety net — a logic fix must be applied in three places (this +already happened with the `<` → `!=` correction in Phase 4), and +a new entry point can omit the checks entirely. + +#### Design: correct by construction + +Replace duplication with a single validation function that is the +**only way** to obtain validated `SessionClaims`. A new entry +point cannot forget the checks because the type system forces it +through the single code path. + +All three structs that need session validation (`Authentication`, +`Service`, and handlers via `AppData`) share the same +`Arc` and `Arc>`. The +consolidated function lives on `JsonWebToken` itself — the +centralised JWT module from Phase 1: + +```rust +impl JsonWebToken { + /// Verify a session JWT and validate it against the database. + /// + /// This is the **sole entry point** for session-token + /// validation. It verifies the JWT signature and expiry, + /// checks the token generation counter, and rejects banned + /// users. + pub async fn validate_session( + &self, + db: &dyn Database, + token: &str, + ) -> Result { + let claims = self.verify(token)?; + + let current_gen = db + .get_token_generation(claims.sub) + .await?; + + if claims.token_gen != current_gen { + return Err(AuthError::TokenRevoked); + } + + if db.is_user_banned(claims.sub).await.unwrap_or(false) { + return Err(AuthError::TokenRevoked); + } + + Ok(claims) + } +} +``` + +#### Callers after consolidation + +**`Authentication::get_user_id_from_bearer_token`** — delegates +directly; remove `validate_token_generation`: + +```rust +pub async fn get_user_id_from_bearer_token( + &self, + token: BearerToken, +) -> Result { + let claims = self.json_web_token + .validate_session(&*self.database, token.as_str()) + .await?; + Ok(claims.sub) +} +``` + +**`verify_token_handler`** — replaces inline verify + check +sequence: + +```rust +pub async fn verify_token_handler( + State(app_data): State>, + extract::Json(token): extract::Json, +) -> Response { + match app_data.json_web_token + .validate_session(&*app_data.database, &token.token) + .await + { + Ok(_) => axum::Json(OkResponseData { + data: "Token is valid.".to_string(), + }) + .into_response(), + Err(error) => error.into_response(), + } +} +``` + +**`Service::renew_token`** — replaces inline verify + check +sequence: + +```rust +pub async fn renew_token( + &self, + token: &str, +) -> Result<(String, UserCompact), AuthError> { + const ONE_WEEK_IN_SECONDS: u64 = 604_800; + + let claims = self.json_web_token + .validate_session(&*self.database, token) + .await?; + + let user_compact = self.user_repository + .get_compact(&claims.sub) + .await + .map_err(|err| match err { + Error::UserNotFound => AuthError::UserNotFound, + err => AuthError::from(err), + })?; + + let token = match claims.exp - clock::now() { + x if x < ONE_WEEK_IN_SECONDS => { + self.json_web_token + .sign(user_compact.clone(), claims.token_gen) + .await? + } + _ => token.to_string(), + }; + + Ok((token, user_compact)) +} +``` + +#### Files affected + +| File | Change | +|---|---| +| `src/jwt.rs` | Add `validate_session` method on `JsonWebToken` | +| `src/web/api/server/v1/auth.rs` | `get_user_id_from_bearer_token` delegates; remove `validate_token_generation` | +| `src/web/api/server/v1/contexts/user/handlers.rs` | `verify_token_handler` delegates | +| `src/services/authentication.rs` | `renew_token` delegates | + +**No breaking change** — internal refactor only. + +## Configuration Migration + +Deployers upgrading across Phases 2–3 must: + +1. Generate an RSA key pair — via + `torrust-generate-auth-keypair` (Phase 6) or `openssl`. +2. Update config to reference key paths (or set env vars). +3. Accept session invalidation (users re-login once). + +With Phase 5, steps 1–2 are **optional** for bare-metal +deployments — ephemeral keys are auto-generated; sessions just +don't survive restarts. + +With Phase 6, **container deployments handle key generation +automatically** on first boot. No manual setup required. + +Note: the serialized default config for bare-metal deployments +no longer contains `private_key_path` / `public_key_path` +entries. Container configs *do* include these paths (pointing to +`/etc/torrust/index/auth/`). Existing configs that explicitly +set these fields are unaffected. + +## Consequences + +- Existing sessions **invalidated** at Phase 2 (claim format) + and Phase 3 (algorithm change). Users re-login. +- **Container:** auto-generated persistent keys on first boot + (Phase 6). Sessions survive restarts. +- **Bare-metal (no config):** ephemeral in-memory keys + (Phase 5). Sessions do not survive restarts. +- **Bare-metal (with keys):** persistent sessions via + deployer-supplied key pair. +- Token revocation via `token_generation` counter (Phase 4). + Password changes, role changes, and bans invalidate + outstanding tokens. +- Centralised `jwt` module makes future algorithm changes + (e.g. EdDSA) a single-module edit. +- External services verify tokens using only the public key. +- The `BearerToken` extractor now rejects missing or malformed + `Authorization` headers at extraction time (Problem #11). + `ExtractOptionalLoggedInUser` catches the rejection for + anonymous endpoints. +- Session validation consolidated into a single code path + (Phase 7). New authentication entry points cannot bypass + revocation or ban checks. + +## Testing Strategy + +The repository ships **no pre-generated RSA key material**. +Tests exercise three provisioning modes: + +### Crate-level (`src/tests/jwt.rs`) + +The `jwt_service()` helper constructs `JsonWebToken` with no +key paths, exercising ephemeral in-memory generation (Phase 5). + +### Isolated e2e (bare-metal) + +Tests start an in-process server with a `TempDir` +configuration. No key paths → auto-generated keys. +Authentication works for the test lifetime. + +### Container e2e (persistent keys) + +`compose.yaml` tests exercise the production flow: entry script +generates keys to the volume, server starts with file-supplied +keys, e2e auth round-trip runs. + +### Host-supplied-key e2e + +A `#[ignore]`-gated test verifies externally-generated keys +(requires `openssl` on `$PATH`): generates a key pair into a +temp dir, starts a server with those keys, runs a full auth +round-trip, restarts the server, and confirms previously-issued +tokens are still valid. diff --git a/compose.yaml b/compose.yaml index 3f63dde5f..223e6a948 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,7 +13,6 @@ services: - TORRUST_INDEX_DATABASE=${TORRUST_INDEX_DATABASE:-e2e_testing_sqlite3} - TORRUST_INDEX_DATABASE_DRIVER=${TORRUST_INDEX_DATABASE_DRIVER:-sqlite3} - TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN:-MyAccessToken} - - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER:-MaxVerstappenWC2021} networks: - server_side ports: diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh index f5151dc86..7c01187ce 100755 --- a/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh +++ b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh @@ -8,7 +8,6 @@ USER_ID=${USER_ID:-1000} \ TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" \ TORRUST_INDEX_DATABASE_DRIVER="sqlite3" \ TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER="MaxVerstappenWC2021" \ TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.private.e2e.container.sqlite3.toml) \ TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER="sqlite3" \ diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh index ebaae5312..3fa63c0ff 100755 --- a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh +++ b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh @@ -8,7 +8,6 @@ USER_ID=${USER_ID:-1000} \ TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" \ TORRUST_INDEX_DATABASE_DRIVER="sqlite3" \ TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER="MaxVerstappenWC2021" \ TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER="sqlite3" \ diff --git a/contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh b/contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh index 9e67a4343..44e58075a 100755 --- a/contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh +++ b/contrib/dev-tools/container/functions/wait_for_container_to_be_healthy.sh @@ -23,4 +23,6 @@ wait_for_container_to_be_healthy() { echo "Timeout reached, container $container_name is not healthy" return 1 -} \ No newline at end of file +} + +wait_for_container_to_be_healthy "$@" \ No newline at end of file diff --git a/docs/containers.md b/docs/containers.md index 5039d363e..b335eb8ce 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -67,10 +67,19 @@ storage/index/ │ └── localhost.key => /var/lib/torrust/index/tls/localhost.key [user supplied] ├── log => /var/log/torrust/index (future use) └── etc + ├── auth + │ ├── private.pem => /etc/torrust/index/auth/private.pem [auto generated on first boot] + │ └── public.pem => /etc/torrust/index/auth/public.pem [auto generated on first boot] └── index.toml => /etc/torrust/index/index.toml [auto populated] ``` > NOTE: you only need the `tls` directory and certificates in case you have enabled SSL. +> +> The `auth/` directory and RSA key pair are auto-generated on first boot by the +> container entry script. Sessions persist across restarts as long as the +> `/etc/torrust/index` volume is retained. To use your own keys, either +> pre-populate the volume before first boot or overwrite the generated files and +> restart. ## Building the Container @@ -149,7 +158,10 @@ The following environmental variables can be set: - `TORRUST_INDEX_CONFIG_TOML_PATH` - The in-container path to the index configuration file, (default: `"/etc/torrust/index/index.toml"`). - `TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN` - Override of the admin token. If set, this value overrides any value set in the config. -- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY` - Override of the auth secret key. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH` - Path to an RSA private key PEM file for JWT signing. Optional: without this, ephemeral auto-generated keys are used (sessions will not survive restarts). +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH` - Path to an RSA public key PEM file for JWT verification. Required when `PRIVATE_KEY_PATH` is set. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PEM` - Inline RSA private key PEM string (alternative to file path). Optional: for persistent sessions. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PEM` - Inline RSA public key PEM string (alternative to file path). Required when `PRIVATE_KEY_PEM` is set. - `TORRUST_INDEX_DATABASE_DRIVER` - The database type used for the container, (options: `sqlite3`, `mysql`, default `sqlite3`). Please Note: This dose not override the database configuration within the `.toml` config file. - `TORRUST_INDEX_CONFIG_TOML` - Load config from this environmental variable instead from a file, (i.e: `TORRUST_INDEX_CONFIG_TOML=$(cat index-index.toml)`). - `USER_ID` - The user id for the runtime crated `torrust` user. Please Note: This user id should match the ownership of the host-mapped volumes, (default `1000`). @@ -200,9 +212,12 @@ docker build --target release --tag torrust-index:release --file Containerfile . mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## Run Torrust Index Container Image +## Note: Without key path env vars, ephemeral auto-generated keys are used. +## For persistent sessions, supply your own RSA key pair: +## --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH="/var/lib/torrust/index/jwt/private.pem" \ +## --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/var/lib/torrust/index/jwt/public.pem" \ docker run -it \ --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ - --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY="MaxVerstappenWC2021" \ --env USER_ID="$(id -u)" \ --publish 0.0.0.0:3001:3001/tcp \ --volume ./storage/index/lib:/var/lib/torrust/index:Z \ diff --git a/migrations/mysql/20260414000000_torrust_user_token_generation.sql b/migrations/mysql/20260414000000_torrust_user_token_generation.sql new file mode 100644 index 000000000..dae8b6238 --- /dev/null +++ b/migrations/mysql/20260414000000_torrust_user_token_generation.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_users ADD COLUMN token_generation BIGINT NOT NULL DEFAULT 0 diff --git a/migrations/sqlite3/20260414000000_torrust_user_token_generation.sql b/migrations/sqlite3/20260414000000_torrust_user_token_generation.sql new file mode 100644 index 000000000..38cc8a205 --- /dev/null +++ b/migrations/sqlite3/20260414000000_torrust_user_token_generation.sql @@ -0,0 +1 @@ +ALTER TABLE torrust_users ADD COLUMN token_generation INTEGER NOT NULL DEFAULT 0 diff --git a/share/container/entry_script_sh b/share/container/entry_script_sh index 1afd9e6b3..90f560a9a 100644 --- a/share/container/entry_script_sh +++ b/share/container/entry_script_sh @@ -78,5 +78,30 @@ echo '[ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd' >> /etc/profile cd /home/torrust || exit 1 +# Generate auth keys if not already present on the volume. +auth_dir="/etc/torrust/index/auth" +private_key="$auth_dir/private.pem" +public_key="$auth_dir/public.pem" +tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) +chmod 0600 "$tmpfile" +trap 'rm -f "$tmpfile"' EXIT + +if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then + mkdir -p "$auth_dir" + chown torrust:torrust "$auth_dir" + chmod 0700 "$auth_dir" + + if ! torrust-generate-auth-keypair > "$tmpfile"; then + echo "ERROR: Failed to generate auth keypair" >&2 + exit 1 + fi + sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > "$private_key" + sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > "$public_key" + rm -f "$tmpfile" + chown torrust:torrust "$private_key" "$public_key" + chmod 0400 "$private_key" + chmod 0440 "$public_key" +fi + # Switch to torrust user exec /bin/su-exec torrust "$@" diff --git a/share/default/config/index.container.mysql.toml b/share/default/config/index.container.mysql.toml index 9bc28f58b..fd8ae638a 100644 --- a/share/default/config/index.container.mysql.toml +++ b/share/default/config/index.container.mysql.toml @@ -15,7 +15,8 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +private_key_path = "/etc/torrust/index/auth/private.pem" +public_key_path = "/etc/torrust/index/auth/public.pem" [database] connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index" diff --git a/share/default/config/index.container.sqlite3.toml b/share/default/config/index.container.sqlite3.toml index ab223343e..d5c1902bb 100644 --- a/share/default/config/index.container.sqlite3.toml +++ b/share/default/config/index.container.sqlite3.toml @@ -15,7 +15,8 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +private_key_path = "/etc/torrust/index/auth/private.pem" +public_key_path = "/etc/torrust/index/auth/public.pem" [database] connect_url = "sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc" diff --git a/share/default/config/index.development.sqlite3.toml b/share/default/config/index.development.sqlite3.toml index 9f00351e7..647406797 100644 --- a/share/default/config/index.development.sqlite3.toml +++ b/share/default/config/index.development.sqlite3.toml @@ -17,7 +17,6 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" # Uncomment if you want to enable TSL for development #[net.tsl] diff --git a/share/default/config/index.private.e2e.container.sqlite3.toml b/share/default/config/index.private.e2e.container.sqlite3.toml index 608bd4190..6137ed6dc 100644 --- a/share/default/config/index.private.e2e.container.sqlite3.toml +++ b/share/default/config/index.private.e2e.container.sqlite3.toml @@ -19,7 +19,8 @@ token = "MyAccessToken" url = "http://tracker:7070" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +private_key_path = "/etc/torrust/index/auth/private.pem" +public_key_path = "/etc/torrust/index/auth/public.pem" [database] connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" diff --git a/share/default/config/index.public.e2e.container.mysql.toml b/share/default/config/index.public.e2e.container.mysql.toml index c6b4550e9..d36248512 100644 --- a/share/default/config/index.public.e2e.container.mysql.toml +++ b/share/default/config/index.public.e2e.container.mysql.toml @@ -17,7 +17,8 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +private_key_path = "/etc/torrust/index/auth/private.pem" +public_key_path = "/etc/torrust/index/auth/public.pem" [database] connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing" diff --git a/share/default/config/index.public.e2e.container.sqlite3.toml b/share/default/config/index.public.e2e.container.sqlite3.toml index 1b807154a..62c726dad 100644 --- a/share/default/config/index.public.e2e.container.sqlite3.toml +++ b/share/default/config/index.public.e2e.container.sqlite3.toml @@ -17,7 +17,8 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +private_key_path = "/etc/torrust/index/auth/private.pem" +public_key_path = "/etc/torrust/index/auth/public.pem" [database] connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" diff --git a/share/default/config/tracker.private.e2e.container.sqlite3.toml b/share/default/config/tracker.private.e2e.container.sqlite3.toml index 647d5cee6..154fc2493 100644 --- a/share/default/config/tracker.private.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.private.e2e.container.sqlite3.toml @@ -8,7 +8,6 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" [core] listed = false diff --git a/share/default/config/tracker.public.e2e.container.sqlite3.toml b/share/default/config/tracker.public.e2e.container.sqlite3.toml index e3f73d0b6..88f70b31c 100644 --- a/share/default/config/tracker.public.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.public.e2e.container.sqlite3.toml @@ -8,7 +8,6 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" [core] listed = false diff --git a/src/app.rs b/src/app.rs index af20428d5..df6c4ebb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,8 +73,8 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running // Build app dependencies let database = Arc::new(database::connect(&database_connect_url).await.expect("Database error.")); - let json_web_token = Arc::new(JsonWebToken::new(configuration.clone())); - let auth = Arc::new(Authentication::new(json_web_token.clone())); + let json_web_token = Arc::new(JsonWebToken::new(configuration.clone()).await); + let auth = Arc::new(Authentication::new(json_web_token.clone(), database.clone())); // Repositories let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); @@ -107,7 +107,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let tracker_service = Arc::new(tracker::service::Service::new(configuration.clone(), database.clone()).await); let tracker_statistics_importer = Arc::new(StatisticsImporter::new(configuration.clone(), tracker_service.clone(), database.clone()).await); - let mailer_service = Arc::new(mailer::Service::new(configuration.clone()).await); + let mailer_service = Arc::new(mailer::Service::new(configuration.clone(), json_web_token.clone()).await); let image_cache_service: Arc = Arc::new(ImageCacheService::new(configuration.clone()).await); let category_service = Arc::new(category::Service::new( category_repository.clone(), @@ -136,6 +136,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running )); let registration_service = Arc::new(user::RegistrationService::new( configuration.clone(), + json_web_token.clone(), mailer_service.clone(), user_repository.clone(), user_profile_repository.clone(), @@ -153,6 +154,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let authentication_service = Arc::new(Service::new( configuration.clone(), json_web_token.clone(), + database.clone(), user_repository.clone(), user_profile_repository.clone(), user_authentication_repository.clone(), diff --git a/src/bin/generate_auth_keypair.rs b/src/bin/generate_auth_keypair.rs new file mode 100644 index 000000000..0488137fd --- /dev/null +++ b/src/bin/generate_auth_keypair.rs @@ -0,0 +1,110 @@ +//! Generate an RSA-2048 key pair for JWT authentication. +//! +//! Outputs both PEM blocks (private key first, then public key) to +//! **stdout**. Diagnostic messages go to **stderr** via `tracing`. +//! +//! The tool refuses to run if stdout is a terminal to prevent +//! accidental display of key material on screen. +//! +//! # Usage +//! +//! ```sh +//! tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) +//! chmod 0600 "$tmpfile" +//! torrust-generate-auth-keypair > "$tmpfile" +//! sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > private.pem +//! sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > public.pem +//! rm -f "$tmpfile" +//! ``` +//! +//! See ADR-T-007 Phase 6 for full context. + +use std::io::{self, IsTerminal, Write}; +use std::process; + +use clap::Parser; +use rsa::RsaPrivateKey; +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; +use tracing::{error, info}; + +#[derive(Parser)] +#[command( + name = "torrust-generate-auth-keypair", + about = "Generate an RSA-2048 key pair for Torrust Index JWT authentication" +)] +struct Args { + /// Enable debug-level logging output on stderr. + #[arg(long)] + debug: bool, +} + +fn main() { + let args = Args::parse(); + + // Initialise tracing subscriber with structured JSON output on stderr. + let level = if args.debug { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }; + tracing_subscriber::fmt() + .json() + .with_max_level(level) + .with_writer(io::stderr) + .init(); + + // Refuse to run if stdout is a terminal. + if io::stdout().is_terminal() { + error!( + hint = "pipe the output to a file to avoid displaying key material on screen", + example = concat!( + "tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) && ", + "chmod 0600 \"$tmpfile\" && ", + "torrust-generate-auth-keypair > \"$tmpfile\" && ", + "sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' \"$tmpfile\" > private.pem && ", + "sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' \"$tmpfile\" > public.pem && ", + "rm -f \"$tmpfile\"", + ), + "stdout is a terminal" + ); + process::exit(1); + } + + info!("Generating RSA-2048 key pair..."); + + let mut rng = rsa::rand_core::OsRng; + let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap_or_else(|e| { + error!(error = %e, "RSA key generation failed"); + process::exit(1); + }); + + let private_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap_or_else(|e| { + error!(error = %e, "private key PEM export failed"); + process::exit(1); + }); + + let public_pem = private_key + .to_public_key() + .to_public_key_pem(LineEnding::LF) + .unwrap_or_else(|e| { + error!(error = %e, "public key PEM export failed"); + process::exit(1); + }); + + info!("Key pair generated successfully."); + + let stdout = io::stdout(); + let mut out = stdout.lock(); + out.write_all(private_pem.as_bytes()).unwrap_or_else(|e| { + error!(error = %e, "failed to write private key"); + process::exit(1); + }); + out.write_all(public_pem.as_bytes()).unwrap_or_else(|e| { + error!(error = %e, "failed to write public key"); + process::exit(1); + }); + out.flush().unwrap_or_else(|e| { + error!(error = %e, "failed to flush stdout"); + process::exit(1); + }); +} diff --git a/src/config/mod.rs b/src/config/mod.rs index e55879b93..b0c634574 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -24,7 +24,6 @@ pub type Registration = v2::registration::Registration; pub type Email = v2::registration::Email; pub type Auth = v2::auth::Auth; -pub type SecretKey = v2::auth::ClaimTokenPepper; pub type PasswordConstraints = v2::auth::PasswordConstraints; pub type Database = v2::database::Database; @@ -349,19 +348,16 @@ impl Configuration { /// Will return an error if a mandatory configuration option is only /// obtained by default value (code), meaning the user hasn't overridden it. fn check_mandatory_options(figment: &Figment) -> Result<(), Error> { - let mandatory_options = [ - "auth.user_claim_token_pepper", - "logging.threshold", - "metadata.schema_version", - "tracker.token", - ]; + let mandatory_options = ["logging.threshold", "metadata.schema_version", "tracker.token"]; for mandatory_option in mandatory_options { - figment - .find_value(mandatory_option) - .map_err(|_err| Error::MissingMandatoryOption { + let found = figment.find_value(mandatory_option).is_ok(); + + if !found { + return Err(Error::MissingMandatoryOption { path: mandatory_option.to_owned(), - })?; + }); + } } Ok(()) diff --git a/src/config/v2/auth.rs b/src/config/v2/auth.rs index 2eb473f89..a52000b50 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -1,15 +1,67 @@ -use std::fmt; +use std::path::Path; use serde::{Deserialize, Serialize}; +use tracing::warn; + +/// Default session-token lifetime: 2 weeks (1 209 600 s). +const DEFAULT_SESSION_TOKEN_LIFETIME_SECS: u64 = 1_209_600; + +/// Default email-verification-token lifetime: ~10 years (315 569 260 s). +const DEFAULT_EMAIL_VERIFICATION_TOKEN_LIFETIME_SECS: u64 = 315_569_260; /// Authentication options. +/// +/// ## JWT signing (ADR-T-007) +/// +/// JWT signing uses RS256 (RSA + SHA-256) with a public/private key +/// pair. Session and email-verification tokens share the same key +/// pair; purpose separation is via the `aud` claim. A per-user +/// `token_generation` counter enables near-instant revocation on +/// password change, role change, or ban. +/// +/// Configuration supports two mechanisms (in priority order): +/// +/// 1. **Inline PEM** — `private_key_pem` / `public_key_pem`. +/// Primarily for passing keys via environment variables +/// (e.g., `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PEM`). +/// 2. **File paths** — `private_key_path` / `public_key_path`. +/// Point to PEM files on disk. +/// +/// If neither is provided, an ephemeral RSA-2048 key pair is +/// auto-generated in memory at startup. Sessions will not survive +/// server restarts. To persist sessions, generate your own key pair +/// and configure the paths or environment variables. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Auth { - /// The secret key used to sign JWT tokens. - #[serde(default = "Auth::default_user_claim_token_pepper")] - pub user_claim_token_pepper: ClaimTokenPepper, + /// Inline RSA private key in PEM format (overrides `private_key_path`). + /// + /// Use this when passing the key via environment variable. + #[serde(default)] + pub private_key_pem: Option, + + /// Inline RSA public key in PEM format (overrides `public_key_path`). + /// + /// Use this when passing the key via environment variable. + #[serde(default)] + pub public_key_pem: Option, + + /// Path to the RSA private key PEM file for JWT signing. + #[serde(default = "Auth::default_private_key_path")] + pub private_key_path: Option, + + /// Path to the RSA public key PEM file for JWT verification. + #[serde(default = "Auth::default_public_key_path")] + pub public_key_path: Option, + + /// Session-token lifetime in seconds (default: 2 weeks). + #[serde(default = "Auth::default_session_token_lifetime_secs")] + pub session_token_lifetime_secs: u64, - /// The password constraints + /// Email-verification-token lifetime in seconds (default: ~10 years). + #[serde(default = "Auth::default_email_verification_token_lifetime_secs")] + pub email_verification_token_lifetime_secs: u64, + + /// The password constraints. #[serde(default = "Auth::default_password_constraints")] pub password_constraints: PasswordConstraints, } @@ -17,49 +69,94 @@ pub struct Auth { impl Default for Auth { fn default() -> Self { Self { + private_key_pem: None, + public_key_pem: None, + private_key_path: None, + public_key_path: None, + session_token_lifetime_secs: Self::default_session_token_lifetime_secs(), + email_verification_token_lifetime_secs: Self::default_email_verification_token_lifetime_secs(), password_constraints: Self::default_password_constraints(), - user_claim_token_pepper: Self::default_user_claim_token_pepper(), } } } impl Auth { - pub fn override_user_claim_token_pepper(&mut self, user_claim_token_pepper: &str) { - self.user_claim_token_pepper = ClaimTokenPepper::new(user_claim_token_pepper); + const fn default_private_key_path() -> Option { + None + } + + const fn default_public_key_path() -> Option { + None + } + + const fn default_session_token_lifetime_secs() -> u64 { + DEFAULT_SESSION_TOKEN_LIFETIME_SECS } - fn default_user_claim_token_pepper() -> ClaimTokenPepper { - ClaimTokenPepper::new("MaxVerstappenWC2021") + const fn default_email_verification_token_lifetime_secs() -> u64 { + DEFAULT_EMAIL_VERIFICATION_TOKEN_LIFETIME_SECS } fn default_password_constraints() -> PasswordConstraints { PasswordConstraints::default() } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ClaimTokenPepper(String); -impl ClaimTokenPepper { + /// Resolve the RSA private key PEM bytes, if configured. + /// + /// Resolution order: + /// 1. Inline PEM (`private_key_pem`) + /// 2. File path (`private_key_path`) + /// + /// Returns `None` if no key is configured or the configured path + /// does not exist. The caller (`JsonWebToken::new`) uses this to + /// decide whether to auto-generate an ephemeral key pair. + /// /// # Panics /// - /// Will panic if the key if empty. + /// Panics if a configured path exists but cannot be read. #[must_use] - pub fn new(key: &str) -> Self { - assert!(!key.is_empty(), "secret key cannot be empty"); + pub fn resolve_private_key_pem(&self) -> Option> { + if let Some(ref pem) = self.private_key_pem { + return Some(pem.as_bytes().to_vec()); + } - Self(key.to_owned()) + if let Some(ref path) = self.private_key_path { + if Path::new(path).exists() { + return Some(std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA private key from `{path}`: {e}"))); + } + warn!("configured private_key_path `{path}` does not exist, falling back to ephemeral keys"); + } + + None } + /// Resolve the RSA public key PEM bytes, if configured. + /// + /// Resolution order: + /// 1. Inline PEM (`public_key_pem`) + /// 2. File path (`public_key_path`) + /// + /// Returns `None` if no key is configured or the configured path + /// does not exist. The caller (`JsonWebToken::new`) uses this to + /// decide whether to auto-generate an ephemeral key pair. + /// + /// # Panics + /// + /// Panics if a configured path exists but cannot be read. #[must_use] - pub fn as_bytes(&self) -> &[u8] { - self.0.as_bytes() - } -} + pub fn resolve_public_key_pem(&self) -> Option> { + if let Some(ref pem) = self.public_key_pem { + return Some(pem.as_bytes().to_vec()); + } + + if let Some(ref path) = self.public_key_path { + if Path::new(path).exists() { + return Some(std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA public key from `{path}`: {e}"))); + } + warn!("configured public_key_path `{path}` does not exist, falling back to ephemeral keys"); + } -impl fmt::Display for ClaimTokenPepper { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) + None } } diff --git a/src/config/v2/mod.rs b/src/config/v2/mod.rs index 8c7a21331..8307f2b45 100644 --- a/src/config/v2/mod.rs +++ b/src/config/v2/mod.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use unstable::Unstable; use self::api::Api; -use self::auth::{Auth, ClaimTokenPepper}; +use self::auth::Auth; use self::database::Database; use self::image_cache::ImageCache; use self::mail::Mail; @@ -111,7 +111,18 @@ impl Settings { let _ = self.database.connect_url.set_password(Some("***")); } "***".clone_into(&mut self.mail.smtp.credentials.password); - self.auth.user_claim_token_pepper = ClaimTokenPepper::new("***"); + if let Some(private_key_pem) = self.auth.private_key_pem.as_mut() { + "***-redacted-private-key-pem***".clone_into(private_key_pem); + } + if let Some(private_key_path) = self.auth.private_key_path.as_mut() { + "***-redacted***".clone_into(private_key_path); + } + if let Some(public_key_pem) = self.auth.public_key_pem.as_mut() { + "***-redacted-public-key-pem***".clone_into(public_key_pem); + } + if let Some(public_key_path) = self.auth.public_key_path.as_mut() { + "***-redacted***".clone_into(public_key_path); + } } /// Encodes the configuration to TOML. diff --git a/src/databases/database.rs b/src/databases/database.rs index 0e606c94c..ddbfc2476 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -204,6 +204,11 @@ pub trait Database: Sync + Send { /// Change user's password. async fn change_user_password(&self, user_id: i64, new_password: &str) -> Result<(), Error>; + /// Change user's password **and** increment `token_generation` in a + /// single transaction, ensuring both succeed or neither does. + /// See ADR-T-007 §A-2a. + async fn change_user_password_and_revoke_tokens(&self, user_id: i64, new_password: &str) -> Result<(), Error>; + /// Get `User` from `user_id`. async fn get_user_from_id(&self, user_id: i64) -> Result; @@ -235,9 +240,31 @@ pub trait Database: Sync + Send { /// Ban user with `user_id`, `reason` and `date_expiry`. async fn ban_user(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), Error>; + /// Ban a user **and** increment `token_generation` in a single + /// transaction, ensuring both succeed or neither does. + /// See ADR-T-007 §A-2c. + async fn ban_user_and_revoke_tokens(&self, user_id: i64, reason: &str, date_expiry: NaiveDateTime) -> Result<(), Error>; + + /// Check whether a user is currently banned (has a non-expired + /// entry in `torrust_user_bans`). Defence-in-depth for the + /// authentication path. See ADR-T-007 §A-3. + async fn is_user_banned(&self, user_id: i64) -> Result; + /// Grant a user the administrator role. async fn grant_admin_role(&self, user_id: i64) -> Result<(), Error>; + /// Grant a user the administrator role **and** increment + /// `token_generation` in a single `UPDATE` statement. + /// See ADR-T-007 §A-2b. + async fn grant_admin_role_and_revoke_tokens(&self, user_id: i64) -> Result<(), Error>; + + /// Get the current `token_generation` counter for a user. + async fn get_token_generation(&self, user_id: i64) -> Result; + + /// Increment the `token_generation` counter for a user, invalidating + /// all outstanding session tokens. + async fn increment_token_generation(&self, user_id: i64) -> Result<(), Error>; + /// Verify a user's email with `user_id`. async fn verify_email(&self, user_id: i64) -> Result<(), Error>; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 0aff1b6bc..cd795470e 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -131,6 +131,52 @@ impl Database for Mysql { }) } + /// Change user's password and increment `token_generation` atomically. + /// See ADR-T-007 §A-2a. + async fn change_user_password_and_revoke_tokens(&self, user_id: i64, new_password: &str) -> Result<(), database::Error> { + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; + + let pw_result = query("UPDATE torrust_user_authentication SET password_hash = ? WHERE user_id = ?") + .bind(new_password) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }); + + if let Err(e) = pw_result { + drop(tx.rollback().await); + return Err(e); + } + + let gen_result = query("UPDATE torrust_users SET token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }); + + if let Err(e) = gen_result { + drop(tx.rollback().await); + return Err(e); + } + + tx.commit().await.map_err(|_| database::Error::Error) + } + async fn get_user_from_id(&self, user_id: i64) -> Result { query_as::<_, User>("SELECT * FROM torrust_users WHERE user_id = ?") .bind(user_id) @@ -284,6 +330,64 @@ impl Database for Mysql { .map_err(|_| database::Error::Error) } + /// Ban a user and increment `token_generation` atomically. + /// See ADR-T-007 §A-2c. + async fn ban_user_and_revoke_tokens( + &self, + user_id: i64, + reason: &str, + date_expiry: NaiveDateTime, + ) -> Result<(), database::Error> { + let date_expiry_string = date_expiry.format("%Y-%m-%d %H:%M:%S").to_string(); + + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; + + let ban_result = query("INSERT INTO torrust_user_bans (user_id, reason, date_expiry) VALUES (?, ?, ?)") + .bind(user_id) + .bind(reason) + .bind(date_expiry_string) + .execute(&mut *tx) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error); + + if let Err(e) = ban_result { + drop(tx.rollback().await); + return Err(e); + } + + let gen_result = query("UPDATE torrust_users SET token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }); + + if let Err(e) = gen_result { + drop(tx.rollback().await); + return Err(e); + } + + tx.commit().await.map_err(|_| database::Error::Error) + } + + /// Defence-in-depth ban check. See ADR-T-007 §A-3. + async fn is_user_banned(&self, user_id: i64) -> Result { + query_as::<_, (i64,)>("SELECT COUNT(*) FROM torrust_user_bans WHERE user_id = ? AND date_expiry > NOW()") + .bind(user_id) + .fetch_one(&self.pool) + .await + .map(|(count,)| count > 0) + .map_err(|_| database::Error::Error) + } + async fn grant_admin_role(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_users SET administrator = TRUE WHERE user_id = ?") .bind(user_id) @@ -299,6 +403,50 @@ impl Database for Mysql { }) } + /// Grant admin role and increment `token_generation` in a single UPDATE. + /// See ADR-T-007 §A-2b. + async fn grant_admin_role_and_revoke_tokens(&self, user_id: i64) -> Result<(), database::Error> { + query("UPDATE torrust_users SET administrator = TRUE, token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }) + } + + async fn get_token_generation(&self, user_id: i64) -> Result { + query_as("SELECT token_generation FROM torrust_users WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => database::Error::UserNotFound, + _ => database::Error::Error, + }) + .and_then(|(v,): (i64,)| u64::try_from(v).map_err(|_| database::Error::Error)) + } + + async fn increment_token_generation(&self, user_id: i64) -> Result<(), database::Error> { + query("UPDATE torrust_users SET token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }) + } + async fn verify_email(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_user_profiles SET email_verified = TRUE WHERE user_id = ?") .bind(user_id) diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 2a39c22c1..d5c207890 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -132,6 +132,52 @@ impl Database for Sqlite { }) } + /// Change user's password and increment `token_generation` atomically. + /// See ADR-T-007 §A-2a. + async fn change_user_password_and_revoke_tokens(&self, user_id: i64, new_password: &str) -> Result<(), database::Error> { + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; + + let pw_result = query("UPDATE torrust_user_authentication SET password_hash = ? WHERE user_id = ?") + .bind(new_password) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }); + + if let Err(e) = pw_result { + drop(tx.rollback().await); + return Err(e); + } + + let gen_result = query("UPDATE torrust_users SET token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }); + + if let Err(e) = gen_result { + drop(tx.rollback().await); + return Err(e); + } + + tx.commit().await.map_err(|_| database::Error::Error) + } + async fn get_user_from_id(&self, user_id: i64) -> Result { query_as::<_, User>("SELECT * FROM torrust_users WHERE user_id = ?") .bind(user_id) @@ -280,6 +326,66 @@ impl Database for Sqlite { .map_err(|_| database::Error::Error) } + /// Ban a user and increment `token_generation` atomically. + /// See ADR-T-007 §A-2c. + async fn ban_user_and_revoke_tokens( + &self, + user_id: i64, + reason: &str, + date_expiry: NaiveDateTime, + ) -> Result<(), database::Error> { + let date_expiry_string = date_expiry.format("%Y-%m-%d %H:%M:%S").to_string(); + + let mut conn = self.pool.acquire().await.map_err(|_| database::Error::Error)?; + let mut tx = conn.begin().await.map_err(|_| database::Error::Error)?; + + let ban_result = query("INSERT INTO torrust_user_bans (user_id, reason, date_expiry) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(reason) + .bind(date_expiry_string) + .execute(&mut *tx) + .await + .map(|_| ()) + .map_err(|_| database::Error::Error); + + if let Err(e) = ban_result { + drop(tx.rollback().await); + return Err(e); + } + + let gen_result = query("UPDATE torrust_users SET token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }); + + if let Err(e) = gen_result { + drop(tx.rollback().await); + return Err(e); + } + + tx.commit().await.map_err(|_| database::Error::Error) + } + + /// Defence-in-depth ban check. See ADR-T-007 §A-3. + async fn is_user_banned(&self, user_id: i64) -> Result { + query_as::<_, (i64,)>( + "SELECT COUNT(*) FROM torrust_user_bans WHERE user_id = ? AND date_expiry > strftime('%Y-%m-%d %H:%M:%S', 'now')", + ) + .bind(user_id) + .fetch_one(&self.pool) + .await + .map(|(count,)| count > 0) + .map_err(|_| database::Error::Error) + } + async fn grant_admin_role(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_users SET administrator = TRUE WHERE user_id = ?") .bind(user_id) @@ -295,6 +401,50 @@ impl Database for Sqlite { }) } + /// Grant admin role and increment `token_generation` in a single UPDATE. + /// See ADR-T-007 §A-2b. + async fn grant_admin_role_and_revoke_tokens(&self, user_id: i64) -> Result<(), database::Error> { + query("UPDATE torrust_users SET administrator = TRUE, token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }) + } + + async fn get_token_generation(&self, user_id: i64) -> Result { + query_as("SELECT token_generation FROM torrust_users WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => database::Error::UserNotFound, + _ => database::Error::Error, + }) + .and_then(|(v,): (i64,)| u64::try_from(v).map_err(|_| database::Error::Error)) + } + + async fn increment_token_generation(&self, user_id: i64) -> Result<(), database::Error> { + query("UPDATE torrust_users SET token_generation = token_generation + 1 WHERE user_id = ?") + .bind(user_id) + .execute(&self.pool) + .await + .map_err(|_| database::Error::Error) + .and_then(|v| { + if v.rows_affected() > 0 { + Ok(()) + } else { + Err(database::Error::UserNotFound) + } + }) + } + async fn verify_email(&self, user_id: i64) -> Result<(), database::Error> { query("UPDATE torrust_user_profiles SET email_verified = TRUE WHERE user_id = ?") .bind(user_id) diff --git a/src/errors.rs b/src/errors.rs index f88b355bf..0c6f1ec19 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -36,6 +36,9 @@ pub enum AuthError { #[error("Token invalid.")] TokenInvalid, + #[error("Token has been revoked. Please sign in again.")] + TokenRevoked, + #[error("Unauthorized action.")] UnauthorizedAction, @@ -70,6 +73,7 @@ impl AuthError { Self::TokenNotFound | Self::TokenExpired | Self::TokenInvalid + | Self::TokenRevoked | Self::LoggedInUserNotFound | Self::UnauthorizedActionForGuests => StatusCode::UNAUTHORIZED, Self::InternalServerError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/jwt.rs b/src/jwt.rs new file mode 100644 index 000000000..2787ebdb9 --- /dev/null +++ b/src/jwt.rs @@ -0,0 +1,370 @@ +//! Centralised JWT (JSON Web Token) module. +//! +//! All `jsonwebtoken` usage is confined to this module: key loading, +//! signing, verification, and algorithm configuration. +//! +//! See ADR-T-007 for the rationale behind centralising JWT handling. +//! +//! # Architecture (ADR-T-007 Phases 1–7) +//! +//! **Phase 1 — Structural cleanup.** Consolidated all `jsonwebtoken` +//! usage into this single module with `Result`-based error propagation. +//! +//! **Phase 2 — Claim redesign.** `SessionClaims` follows RFC 7519 +//! registered claim names (`sub`, `iss`, `aud`, `iat`, `exp`) with +//! advisory `role`/`username` fields. Purpose separation between +//! session and email-verification tokens is via the `aud` claim. +//! +//! **Phase 3 — RS256 asymmetric signing.** Switched from HMAC-HS256 +//! to RS256 (RSA + SHA-256). A single RSA key pair is used for all +//! token purposes. `EncodingKey` (private) signs; `DecodingKey` +//! (public) verifies. A `kid` (Key ID) is included in every JWT +//! header for future key rotation. Keys are resolved once at +//! construction time from PEM files or inline PEM config. +//! +//! **Phase 4 — Token revocation.** `SessionClaims` includes a `gen` +//! (token generation) counter. Password changes, role changes, and +//! bans increment the counter in the database; tokens carrying an +//! older `gen` are rejected at verification time. +//! +//! **Phase 5 — Ephemeral auto-generated keys.** When no key paths or +//! inline PEM values are configured, an RSA-2048 key pair is +//! auto-generated in memory at startup. The keys are never written to +//! disk; sessions do not survive server restarts. Deployers who want +//! persistent sessions supply their own key pair via config. +//! +//! **Phase 6 — `generate-auth-keypair` CLI.** A standalone binary +//! (`torrust-generate-auth-keypair`) generates an RSA-2048 key pair +//! and writes both PEM blocks to stdout. The container entry script +//! uses it to auto-generate persistent keys on first boot. See +//! `src/bin/generate_auth_keypair.rs` for the binary and ADR-T-007 +//! Phase 6 for full context. +//! +//! **Phase 7 — Consolidated session validation.** `validate_session` +//! is the sole entry point for session-token validation: it verifies +//! the JWT, checks the token-generation counter, and rejects banned +//! users. All callers delegate here instead of re-implementing the +//! sequence. See ADR-T-007 Phase 7. + +use std::sync::Arc; + +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use rsa::RsaPrivateKey; +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tracing::info; + +use crate::config::Configuration; +use crate::databases::database::Database; +use crate::errors::AuthError; +use crate::models::user::{UserCompact, UserId}; +use crate::utils::clock; + +// ── Issuer constant ────────────────────────────────────────────────── + +const ISSUER: &str = "torrust-index"; + +// ── Claim types ────────────────────────────────────────────────────── + +/// Claims embedded in session (login) JWTs. +/// +/// Follows RFC 7519 registered claim names: +/// - `sub` — subject (user ID) +/// - `iss` — issuer (`"torrust-index"`) +/// - `aud` — audience (`"session"`) +/// - `iat` — issued-at (epoch seconds) +/// - `exp` — expiration (epoch seconds) +/// +/// `role` and `username` are **advisory only**. The authoritative role +/// is always re-checked from the database on each authenticated request. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SessionClaims { + /// Subject — the user ID. + pub sub: UserId, + /// Issuer. + pub iss: String, + /// Audience. + pub aud: String, + /// Issued-at (epoch seconds). + pub iat: u64, + /// Expiration (epoch seconds). + pub exp: u64, + /// Advisory role: `"admin"` or `"user"`. Non-authoritative. + pub role: String, + /// Advisory username. Non-authoritative. + pub username: String, + /// Token generation counter. Validated by + /// [`JsonWebToken::validate_session`] — tokens whose `gen` does + /// not match the current database value are rejected. + /// See ADR-T-007 Phases 4 & 7. + #[serde(rename = "gen")] + pub token_gen: u64, +} + +/// Backward-compatible type alias. +/// +/// Existing code that imports `UserClaims` will keep compiling. +/// New code should prefer [`SessionClaims`]. +pub type UserClaims = SessionClaims; + +/// Claims embedded in email-verification JWTs. +/// +/// Includes `aud: "email-verification"` for purpose separation, +/// and `iss: "torrust-index"`. +#[derive(Debug, Serialize, Deserialize)] +pub struct VerifyClaims { + pub iss: String, + pub aud: String, + pub sub: i64, + pub iat: u64, + pub exp: u64, +} + +// ── Service ────────────────────────────────────────────────────────── + +/// Centralised JWT signing and verification service. +/// +/// Holds pre-loaded RSA keys (`EncodingKey` from the private key, +/// `DecodingKey` from the public key) and a reference to +/// [`Configuration`] for token lifetimes at runtime. +pub struct JsonWebToken { + cfg: Arc, + encoding_key: EncodingKey, + decoding_key: DecodingKey, + kid: String, +} + +impl JsonWebToken { + /// Create a new `JsonWebToken` service. + /// + /// Key resolution: + /// 1. Inline PEM or file path from configuration → host-supplied keys. + /// 2. Neither configured → auto-generate an ephemeral RSA-2048 key + /// pair in memory (keys are never written to disk). + /// + /// # Panics + /// + /// Panics if a configured key path exists but contains invalid PEM. + pub async fn new(cfg: Arc) -> Self { + let settings = cfg.settings.read().await; + let private_pem_opt = settings.auth.resolve_private_key_pem(); + let public_pem_opt = settings.auth.resolve_public_key_pem(); + drop(settings); + + let (private_pem, public_pem) = match (private_pem_opt, public_pem_opt) { + (Some(priv_pem), Some(pub_pem)) => (priv_pem, pub_pem), + (None, None) => { + info!( + "Using ephemeral auto-generated RSA key pair. \ + Sessions will not survive server restarts. \ + To persist sessions, configure auth.private_key_path / auth.public_key_path." + ); + generate_ephemeral_key_pair().await + } + (Some(_), None) => { + panic!( + "RSA private key is configured but public key is missing. \ + Set both auth.private_key_path / auth.private_key_pem and \ + auth.public_key_path / auth.public_key_pem, or remove both \ + to use ephemeral auto-generated keys." + ); + } + (None, Some(_)) => { + panic!( + "RSA public key is configured but private key is missing. \ + Set both auth.private_key_path / auth.private_key_pem and \ + auth.public_key_path / auth.public_key_pem, or remove both \ + to use ephemeral auto-generated keys." + ); + } + }; + + let encoding_key = EncodingKey::from_rsa_pem(&private_pem) + .expect("Invalid RSA private key PEM — check auth.private_key_path or auth.private_key_pem"); + let decoding_key = DecodingKey::from_rsa_pem(&public_pem) + .expect("Invalid RSA public key PEM — check auth.public_key_path or auth.public_key_pem"); + + let kid = compute_kid(&public_pem); + + Self { + cfg, + encoding_key, + decoding_key, + kid, + } + } + + /// Sign a session JWT for the given user. + /// + /// # Errors + /// + /// Returns `AuthError::InternalServerError` if the token cannot be + /// encoded. + pub async fn sign(&self, user: UserCompact, token_generation: u64) -> Result { + let settings = self.cfg.settings.read().await; + let now = clock::now(); + let exp_date = now + settings.auth.session_token_lifetime_secs; + drop(settings); + + let claims = SessionClaims { + sub: user.user_id, + iss: ISSUER.to_owned(), + aud: "session".to_owned(), + iat: now, + exp: exp_date, + role: if user.administrator { + "admin".to_owned() + } else { + "user".to_owned() + }, + username: user.username, + token_gen: token_generation, + }; + + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(self.kid.clone()); + + encode(&header, &claims, &self.encoding_key).map_err(|_| AuthError::InternalServerError) + } + + /// Verify a session JWT and return its claims. + /// + /// Expiration is validated by the `jsonwebtoken` library; there is + /// no redundant manual check. The `aud` claim is validated as + /// `"session"`. + /// + /// # Errors + /// + /// * `AuthError::TokenExpired` — the token's `exp` is in the past. + /// * `AuthError::TokenInvalid` — signature mismatch or malformed token. + pub fn verify(&self, token: &str) -> Result { + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&["session"]); + validation.set_issuer(&[ISSUER]); + + decode::(token, &self.decoding_key, &validation) + .map(|token_data| token_data.claims) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired, + _ => AuthError::TokenInvalid, + }) + } + + /// Verify a session JWT and validate it against the database. + /// + /// This is the **sole entry point** for session-token validation. + /// It verifies the JWT signature and expiry, checks the token + /// generation counter, and rejects banned users. + /// + /// # Errors + /// + /// * `AuthError::TokenExpired` — the token's `exp` is in the past. + /// * `AuthError::TokenInvalid` — signature mismatch or malformed token. + /// * `AuthError::TokenRevoked` — generation mismatch or user is banned. + pub async fn validate_session(&self, db: &dyn Database, token: &str) -> Result { + let claims = self.verify(token)?; + + let current_gen = db.get_token_generation(claims.sub).await?; + + if claims.token_gen != current_gen { + return Err(AuthError::TokenRevoked); + } + + if db.is_user_banned(claims.sub).await? { + return Err(AuthError::TokenRevoked); + } + + Ok(claims) + } + + /// Sign an email-verification JWT for the given user ID. + /// + /// Uses the same RSA key pair as session tokens; purpose + /// separation is via the `aud` claim. + /// + /// # Errors + /// + /// Returns `AuthError::InternalServerError` if encoding fails. + pub async fn sign_email_verification(&self, user_id: i64) -> Result { + let settings = self.cfg.settings.read().await; + let now = clock::now(); + let exp = now + settings.auth.email_verification_token_lifetime_secs; + drop(settings); + + let claims = VerifyClaims { + iss: ISSUER.to_owned(), + aud: "email-verification".to_owned(), + sub: user_id, + iat: now, + exp, + }; + + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(self.kid.clone()); + + encode(&header, &claims, &self.encoding_key).map_err(|_| AuthError::InternalServerError) + } + + /// Verify an email-verification JWT and return its claims. + /// + /// Validates `iss` and `aud` claims. + /// + /// # Errors + /// + /// * `AuthError::TokenExpired` — the token's `exp` is in the past. + /// * `AuthError::TokenInvalid` — bad signature, wrong issuer/audience, or malformed. + pub fn verify_email_token(&self, token: &str) -> Result { + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&["email-verification"]); + validation.set_issuer(&[ISSUER]); + + decode::(token, &self.decoding_key, &validation) + .map(|token_data| token_data.claims) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired, + _ => AuthError::TokenInvalid, + }) + } +} + +/// Compute a deterministic Key ID from the public key PEM. +/// +/// Uses the first 16 hex characters (8 bytes) of the SHA-256 hash of +/// the PEM content. This is stable across restarts for the same key +/// and supports future key rotation via the JWT `kid` header. +fn compute_kid(public_key_pem: &[u8]) -> String { + let hash = Sha256::digest(public_key_pem); + hex::encode(&hash[..8]) +} + +/// Generate an ephemeral RSA-2048 key pair in memory. +/// +/// The generation is CPU-intensive (~100-300 ms) and is offloaded to +/// a blocking thread via `tokio::task::spawn_blocking`. +/// +/// Returns `(private_pem_bytes, public_pem_bytes)`. +/// +/// # Panics +/// +/// Panics if RSA key generation or PEM export fails (indicates a bug +/// in the `rsa` crate or a system RNG failure). +async fn generate_ephemeral_key_pair() -> (Vec, Vec) { + tokio::task::spawn_blocking(|| { + let mut rng = rsa::rand_core::OsRng; + let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("RSA-2048 key generation failed"); + + let private_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .expect("RSA private key PEM export failed"); + + let public_pem = private_key + .to_public_key() + .to_public_key_pem(LineEnding::LF) + .expect("RSA public key PEM export failed"); + + (private_pem.as_bytes().to_vec(), public_pem.into_bytes()) + }) + .await + .expect("ephemeral key generation task panicked") +} diff --git a/src/lib.rs b/src/lib.rs index bf2a2b472..dd1fb5282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,7 +189,6 @@ //! bind_address = "0.0.0.0:3001" //! //! [auth] -//! user_claim_token_pepper = "MaxVerstappenWC2021" //! //! [auth.password_constraints] //! min_password_length = 6 @@ -288,6 +287,7 @@ pub mod config; pub mod console; pub mod databases; pub mod errors; +pub mod jwt; pub mod mailer; pub mod models; pub mod services; diff --git a/src/mailer.rs b/src/mailer.rs index 8f0e5187d..926290e10 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -2,18 +2,18 @@ use std::collections::HashMap; use std::io::ErrorKind; use std::sync::{Arc, LazyLock}; -use jsonwebtoken::{EncodingKey, Header, encode}; use lettre::message::{MessageBuilder, MultiPart, SinglePart}; use lettre::transport::smtp::authentication::{Credentials, Mechanism}; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; -use serde::{Deserialize, Serialize}; use serde_json::value::{Value, to_value}; use tera::{Context, Tera, try_get_value}; use tracing::error; use crate::config::Configuration; use crate::errors::UserError; -use crate::utils::clock; +use crate::jwt::JsonWebToken; +// Re-export so existing `use crate::mailer::VerifyClaims` paths keep compiling. +pub use crate::jwt::VerifyClaims; use crate::web::api::server::v1::routes::API_VERSION_URL_PREFIX; /// Default verify-email template, compiled into the binary. @@ -64,21 +64,19 @@ pub fn do_nothing_filter(value: &Value, _: &HashMap) -> tera::Res pub struct Service { cfg: Arc, + json_web_token: Arc, mailer: Arc, } -#[derive(Debug, Serialize, Deserialize)] -pub struct VerifyClaims { - pub iss: String, - pub sub: i64, - pub exp: u64, -} - impl Service { - pub async fn new(cfg: Arc) -> Self { + pub async fn new(cfg: Arc, json_web_token: Arc) -> Self { let mailer = Arc::new(Self::get_mailer(&cfg).await); - Self { cfg, mailer } + Self { + cfg, + json_web_token, + mailer, + } } async fn get_mailer(cfg: &Configuration) -> Mailer { @@ -115,7 +113,7 @@ impl Service { /// This function will panic if the multipart builder had an error. pub async fn send_verification_mail(&self, to: &str, username: &str, user_id: i64, base_url: &str) -> Result<(), UserError> { let builder = self.get_builder(to).await; - let verification_url = self.get_verification_url(user_id, base_url).await; + let verification_url = self.get_verification_url(user_id, base_url).await?; let mail = build_letter(verification_url.as_str(), username, builder)?; @@ -137,21 +135,14 @@ impl Service { .to(to.parse().unwrap()) } - async fn get_verification_url(&self, user_id: i64, base_url: &str) -> String { - let settings = self.cfg.settings.read().await; - - // create verification JWT - let key = settings.auth.user_claim_token_pepper.as_bytes(); - - // Create non expiring token that is only valid for email-verification - let claims = VerifyClaims { - iss: String::from("email-verification"), - sub: user_id, - exp: clock::now() + 315_569_260, // 10 years from now - }; - - let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).unwrap(); + async fn get_verification_url(&self, user_id: i64, base_url: &str) -> Result { + let token = self + .json_web_token + .sign_email_verification(user_id) + .await + .map_err(|_| UserError::InternalServerError)?; + let settings = self.cfg.settings.read().await; let base_url = settings .net .base_url @@ -159,7 +150,7 @@ impl Service { .map_or_else(|| base_url.to_string(), std::string::ToString::to_string); drop(settings); - format!("{base_url}/{API_VERSION_URL_PREFIX}/user/email/verify/{token}") + Ok(format!("{base_url}/{API_VERSION_URL_PREFIX}/user/email/verify/{token}")) } } diff --git a/src/models/user.rs b/src/models/user.rs index 326d2c094..c925e61e6 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -67,13 +67,6 @@ pub struct UserListing { pub administrator: bool, } -#[allow(clippy::module_name_repetitions)] -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct UserClaims { - pub user: UserCompact, - pub exp: u64, // epoch in seconds -} - pub(crate) const MAX_USERNAME_LENGTH: usize = 20; const USERNAME_VALIDATION_ERROR_MSG: &str = "Usernames must consist of 1-20 alphanumeric characters, dashes, or underscore"; diff --git a/src/services/authentication.rs b/src/services/authentication.rs index ac69e45d6..49353a781 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -1,21 +1,36 @@ //! Authentication services. +//! +//! Provides login (username + password → JWT) and token renewal. +//! JWT signing and verification are delegated to [`crate::jwt::JsonWebToken`]. +//! +//! ## Token revocation (ADR-T-007 Phases 4 & 7) +//! +//! On login, the service fetches the user's current +//! `token_generation` from the database and embeds it in the JWT +//! (`gen` claim). On renewal, validation is delegated to +//! [`JsonWebToken::validate_session`](crate::jwt::JsonWebToken::validate_session), +//! which verifies the JWT, checks the generation counter, and +//! rejects banned users in a single code path. use std::sync::Arc; use argon2::{Argon2, PasswordHash, PasswordVerifier}; -use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use pbkdf2::Pbkdf2; use super::user::DbUserProfileRepository; use crate::config::Configuration; use crate::databases::database::{Database, Error}; use crate::errors::AuthError; -use crate::models::user::{UserAuthentication, UserClaims, UserCompact, UserId}; +// Re-export so that existing `use crate::services::authentication::JsonWebToken` +// paths keep compiling. +pub use crate::jwt::JsonWebToken; +use crate::models::user::{UserAuthentication, UserCompact, UserId}; use crate::services::user::Repository; use crate::utils::clock; pub struct Service { configuration: Arc, json_web_token: Arc, + database: Arc>, user_repository: Arc>, user_profile_repository: Arc, user_authentication_repository: Arc, @@ -25,6 +40,7 @@ impl Service { pub fn new( configuration: Arc, json_web_token: Arc, + database: Arc>, user_repository: Arc>, user_profile_repository: Arc, user_authentication_repository: Arc, @@ -32,6 +48,7 @@ impl Service { Self { configuration, json_web_token, + database, user_repository, user_profile_repository, user_authentication_repository, @@ -90,8 +107,11 @@ impl Service { err => AuthError::from(err), })?; + // Fetch the current token generation for this user + let token_generation = self.database.get_token_generation(user_compact.user_id).await?; + // Sign JWT with compact user details as payload - let token = self.json_web_token.sign(user_compact.clone()).await; + let token = self.json_web_token.sign(user_compact.clone(), token_generation).await?; Ok((token, user_compact)) } @@ -107,21 +127,16 @@ impl Service { pub async fn renew_token(&self, token: &str) -> Result<(String, UserCompact), AuthError> { const ONE_WEEK_IN_SECONDS: u64 = 604_800; - // Verify if token is valid - let claims = self.json_web_token.verify(token).await?; + let claims = self.json_web_token.validate_session(&**self.database, token).await?; - let user_compact = self - .user_repository - .get_compact(&claims.user.user_id) - .await - .map_err(|err| match err { - Error::UserNotFound => AuthError::UserNotFound, - err => AuthError::from(err), - })?; + let user_compact = self.user_repository.get_compact(&claims.sub).await.map_err(|err| match err { + Error::UserNotFound => AuthError::UserNotFound, + err => AuthError::from(err), + })?; // Renew token if it is valid for less than one week - let token = match claims.exp - clock::now() { - x if x < ONE_WEEK_IN_SECONDS => self.json_web_token.sign(user_compact.clone()).await, + let token = match claims.exp.saturating_sub(clock::now()) { + x if x < ONE_WEEK_IN_SECONDS => self.json_web_token.sign(user_compact.clone(), claims.token_gen).await?, _ => token.to_string(), }; @@ -129,59 +144,6 @@ impl Service { } } -pub struct JsonWebToken { - cfg: Arc, -} - -impl JsonWebToken { - pub const fn new(cfg: Arc) -> Self { - Self { cfg } - } - - /// Create Json Web Token. - /// - /// # Panics - /// - /// This function will panic if the default encoding algorithm does not ç - /// match the encoding key. - pub async fn sign(&self, user: UserCompact) -> String { - let key = self.cfg.settings.read().await.auth.user_claim_token_pepper.clone(); - - // Create JWT that expires in two weeks - let key = key.as_bytes(); - - // todo: create config option for setting the token validity in seconds. - let exp_date = clock::now() + 1_209_600; // two weeks from now - - let claims = UserClaims { user, exp: exp_date }; - - encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).expect("argument `Header` should match `EncodingKey`") - } - - /// Verify Json Web Token. - /// - /// # Errors - /// - /// This function will return an error if the JWT is not good or expired. - pub async fn verify(&self, token: &str) -> Result { - let settings = self.cfg.settings.read().await; - - match decode::( - token, - &DecodingKey::from_secret(settings.auth.user_claim_token_pepper.as_bytes()), - &Validation::new(Algorithm::HS256), - ) { - Ok(token_data) => { - if token_data.claims.exp < clock::now() { - return Err(AuthError::TokenExpired); - } - Ok(token_data.claims) - } - Err(_) => Err(AuthError::TokenInvalid), - } - } -} - pub struct DbUserAuthenticationRepository { database: Arc>, } @@ -210,6 +172,28 @@ impl DbUserAuthenticationRepository { pub async fn change_password(&self, user_id: UserId, password_hash: &str) -> Result<(), Error> { self.database.change_user_password(user_id, password_hash).await } + + /// Change password and increment `token_generation` atomically. + /// See ADR-T-007 §A-2a. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn change_password_and_revoke_tokens(&self, user_id: UserId, password_hash: &str) -> Result<(), Error> { + self.database + .change_user_password_and_revoke_tokens(user_id, password_hash) + .await + } + + /// Increment the user's `token_generation` counter, invalidating all + /// outstanding session tokens. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn increment_token_generation(&self, user_id: UserId) -> Result<(), Error> { + self.database.increment_token_generation(user_id).await + } } /// Verify if the user supplied and the database supplied passwords match diff --git a/src/services/user.rs b/src/services/user.rs index f94a071fb..4e0ebfd62 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -1,11 +1,11 @@ //! User services. use std::str::FromStr; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; use async_trait::async_trait; -use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use chrono::NaiveDate; #[cfg(test)] use mockall::automock; use pbkdf2::password_hash::rand_core::OsRng; @@ -17,7 +17,7 @@ use super::authorization::{self, ACTION}; use crate::config::{Configuration, PasswordConstraints}; use crate::databases::database::{Database, Error, UsersFilters, UsersSorting}; use crate::errors::UserError; -use crate::mailer::VerifyClaims; +use crate::jwt::JsonWebToken; use crate::models::response::UserProfilesResponse; use crate::models::user::{UserCompact, UserId, UserProfile, Username}; use crate::services::authentication::verify_password; @@ -25,6 +25,14 @@ use crate::utils::validation::validate_email_address; use crate::web::api::server::v1::contexts::user::forms::{ChangePasswordForm, RegistrationForm}; use crate::{AsCSV, mailer}; +/// Permanent ban expiry date (year 9999). +static PERMANENT_BAN_EXPIRY: LazyLock = LazyLock::new(|| { + NaiveDate::from_ymd_opt(9999, 1, 1) + .expect("valid date") + .and_hms_opt(0, 0, 0) + .expect("valid time") +}); + /// Since user email could be optional, we need a way to represent "no email" /// in the database. This function returns the string that should be used for /// that purpose. @@ -56,6 +64,7 @@ pub struct ListingSpecification { pub struct RegistrationService { configuration: Arc, + json_web_token: Arc, mailer: Arc, user_repository: Arc>, user_profile_repository: Arc, @@ -65,12 +74,14 @@ impl RegistrationService { #[must_use] pub fn new( configuration: Arc, + json_web_token: Arc, mailer: Arc, user_repository: Arc>, user_profile_repository: Arc, ) -> Self { Self { configuration, + json_web_token, mailer, user_repository, user_profile_repository, @@ -184,25 +195,10 @@ impl RegistrationService { /// This function will return a `UserError::DatabaseError` if unable to /// update the user's email verification status. pub async fn verify_email(&self, token: &str) -> Result { - let settings = self.configuration.settings.read().await; - - let token_data = match decode::( - token, - &DecodingKey::from_secret(settings.auth.user_claim_token_pepper.as_bytes()), - &Validation::new(Algorithm::HS256), - ) { - Ok(token_data) => { - if !token_data.claims.iss.eq("email-verification") { - return Ok(false); - } - - token_data.claims - } - Err(_) => return Ok(false), + let Ok(token_data) = self.json_web_token.verify_email_token(token) else { + return Ok(false); }; - drop(settings); - let user_id = token_data.sub; if self.user_profile_repository.verify_email(&user_id).await.is_err() { @@ -284,8 +280,9 @@ impl ProfileService { let password_hash = hash_password(&change_password_form.password)?; + // Atomically change password and revoke tokens (ADR-T-007 §A-2a) self.user_authentication_repository - .change_password(user_id, &password_hash) + .change_password_and_revoke_tokens(user_id, &password_hash) .await?; Ok(()) @@ -335,7 +332,8 @@ impl BanService { .get_user_profile_from_username(username_to_be_banned) .await?; - self.banned_user_list.add(&user_profile.user_id).await?; + // Atomically ban and revoke tokens (ADR-T-007 §A-2c) + self.banned_user_list.add_and_revoke_tokens(&user_profile.user_id).await?; Ok(()) } @@ -478,7 +476,8 @@ impl Repository for DbUserRepository { /// /// It returns an error if there is a database error. async fn grant_admin_role(&self, user_id: &UserId) -> Result<(), Error> { - self.database.grant_admin_role(*user_id).await + // Atomically grant admin and revoke tokens (ADR-T-007 §A-2b) + self.database.grant_admin_role_and_revoke_tokens(*user_id).await } /// It deletes the user. @@ -561,11 +560,6 @@ impl DbBannedUserList { /// # Errors /// /// It returns an error if there is a database error. - /// - /// # Panics - /// - /// It panics if the expiration date cannot be parsed. It should never - /// happen as the date is hardcoded for now. pub async fn add(&self, user_id: &UserId) -> Result<(), Error> { // todo: add reason and `date_expiry` parameters to request. @@ -574,11 +568,31 @@ impl DbBannedUserList { // For the time being, we will not use a reason for banning a user. let reason = "no reason".to_string(); - // User will be banned until the year 9999 - let date_expiry = chrono::NaiveDateTime::parse_from_str("9999-01-01 00:00:00", "%Y-%m-%d %H:%M:%S") - .expect("Could not parse date from 9999-01-01 00:00:00."); + self.database.ban_user(*user_id, &reason, *PERMANENT_BAN_EXPIRY).await + } - self.database.ban_user(*user_id, &reason, date_expiry).await + /// Ban a user and atomically increment `token_generation`. + /// See ADR-T-007 §A-2c. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn add_and_revoke_tokens(&self, user_id: &UserId) -> Result<(), Error> { + let reason = "no reason".to_string(); + + self.database + .ban_user_and_revoke_tokens(*user_id, &reason, *PERMANENT_BAN_EXPIRY) + .await + } + + /// Increment the user's `token_generation` counter, invalidating all + /// outstanding session tokens. + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn increment_token_generation(&self, user_id: &UserId) -> Result<(), Error> { + self.database.increment_token_generation(*user_id).await } } diff --git a/src/tests/config/mod.rs b/src/tests/config/mod.rs index 401d88ea3..daba68e99 100644 --- a/src/tests/config/mod.rs +++ b/src/tests/config/mod.rs @@ -2,7 +2,7 @@ mod v2; use url::Url; -use crate::config::{ApiToken, Configuration, Info, SecretKey, Settings}; +use crate::config::{ApiToken, Configuration, Info, Settings}; fn default_config_toml() -> String { use std::fs; @@ -97,7 +97,6 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - user_claim_token_pepper = "MaxVerstappenWC2021" "#, )?; @@ -129,7 +128,6 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - user_claim_token_pepper = "MaxVerstappenWC2021" "# .to_string(); @@ -170,14 +168,14 @@ async fn configuration_should_allow_to_override_the_tracker_api_token_provided_i #[tokio::test] #[allow(clippy::result_large_err)] -async fn configuration_should_allow_to_override_the_authentication_user_claim_token_pepper_provided_in_the_toml_file() { +async fn configuration_should_allow_to_override_the_private_key_path_provided_in_the_toml_file() { figment::Jail::expect_with(|jail| { jail.create_dir("templates")?; jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; jail.set_env( - "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__USER_CLAIM_TOKEN_PEPPER", - "OVERRIDDEN AUTH SECRET KEY", + "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH", + "/custom/path/private.pem", ); let info = Info { @@ -187,10 +185,7 @@ async fn configuration_should_allow_to_override_the_authentication_user_claim_to let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); - assert_eq!( - settings.auth.user_claim_token_pepper, - SecretKey::new("OVERRIDDEN AUTH SECRET KEY") - ); + assert_eq!(settings.auth.private_key_path, Some("/custom/path/private.pem".to_owned())); Ok(()) }); diff --git a/src/tests/config/v2/auth.rs b/src/tests/config/v2/auth.rs index 70ba1dec5..7a23ee09d 100644 --- a/src/tests/config/v2/auth.rs +++ b/src/tests/config/v2/auth.rs @@ -1,7 +1,42 @@ -use crate::config::v2::auth::ClaimTokenPepper; +//! Tests for `config::v2::auth::Auth` — RSA key resolution. +//! +//! ## Index +//! +//! - `resolve_private_key_pem_from_inline` — inline PEM takes priority. +//! - `resolve_public_key_pem_from_inline` — inline PEM takes priority. +//! - `resolve_private_key_pem_returns_none_when_no_key` — returns `None` if +//! no key is configured (ephemeral generation is handled by `JsonWebToken`). + +use crate::config::v2::auth::Auth; + +#[test] +fn resolve_private_key_pem_from_inline() { + let auth = Auth { + private_key_pem: Some("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n".to_owned()), + private_key_path: None, + ..Auth::default() + }; + let pem = auth.resolve_private_key_pem().expect("should resolve inline PEM"); + assert!(pem.starts_with(b"-----BEGIN PRIVATE KEY-----")); +} + +#[test] +fn resolve_public_key_pem_from_inline() { + let auth = Auth { + public_key_pem: Some("-----BEGIN PUBLIC KEY-----\nfake\n-----END PUBLIC KEY-----\n".to_owned()), + public_key_path: None, + ..Auth::default() + }; + let pem = auth.resolve_public_key_pem().expect("should resolve inline PEM"); + assert!(pem.starts_with(b"-----BEGIN PUBLIC KEY-----")); +} #[test] -#[should_panic(expected = "secret key cannot be empty")] -fn secret_key_can_not_be_empty() { - drop(ClaimTokenPepper::new("")); +fn resolve_private_key_pem_returns_none_when_no_key() { + let auth = Auth { + private_key_pem: None, + private_key_path: None, + ..Auth::default() + }; + assert!(auth.resolve_private_key_pem().is_none()); } diff --git a/src/tests/jwt.rs b/src/tests/jwt.rs new file mode 100644 index 000000000..23e917036 --- /dev/null +++ b/src/tests/jwt.rs @@ -0,0 +1,164 @@ +//! Crate tests for the centralised JWT module (`src/jwt.rs`). +//! +//! # Test index +//! +//! ## Session tokens +//! +//! - [`sign_and_verify_session_token`] — round-trip sign → verify. +//! - [`session_token_contains_expected_claims`] — `sub`, `iss`, `aud`, +//! `role`, `username`, `gen`. +//! - [`session_token_admin_role`] — admin users get `role: "admin"`. +//! - [`session_token_has_kid_header`] — `kid` is present in the JWT +//! header. +//! - [`session_token_rejected_with_email_verification_audience`] — +//! audience mismatch → `TokenInvalid`. +//! +//! ## Email-verification tokens +//! +//! - [`sign_and_verify_email_verification_token`] — round-trip. +//! - [`email_token_contains_expected_claims`] — `sub`, `iss`, `aud`. +//! - [`email_token_rejected_with_session_audience`] — audience +//! mismatch → `TokenInvalid`. +//! +//! ## Error paths +//! +//! - [`verify_rejects_garbage_token`] — random string → `TokenInvalid`. +//! - [`verify_rejects_tampered_token`] — flipped character → +//! `TokenInvalid`. + +use std::sync::Arc; + +use crate::config::Configuration; +use crate::errors::AuthError; +use crate::jwt::JsonWebToken; +use crate::models::user::UserCompact; + +/// Build a `JsonWebToken` service using ephemeral auto-generated keys +/// (the default when no key paths are configured). +async fn jwt_service() -> JsonWebToken { + let cfg = Arc::new(Configuration::default()); + JsonWebToken::new(cfg).await +} + +fn test_user(admin: bool) -> UserCompact { + UserCompact { + user_id: 42, + username: "testuser".to_string(), + administrator: admin, + } +} + +// ── Session tokens ─────────────────────────────────────────────────── + +#[tokio::test] +async fn sign_and_verify_session_token() { + let jwt = jwt_service().await; + let token = jwt.sign(test_user(false), 0).await.unwrap(); + let claims = jwt.verify(&token).unwrap(); + + assert_eq!(claims.sub, 42); + assert_eq!(claims.username, "testuser"); +} + +#[tokio::test] +async fn session_token_contains_expected_claims() { + let jwt = jwt_service().await; + let token = jwt.sign(test_user(false), 7).await.unwrap(); + let claims = jwt.verify(&token).unwrap(); + + assert_eq!(claims.iss, "torrust-index"); + assert_eq!(claims.aud, "session"); + assert_eq!(claims.role, "user"); + assert_eq!(claims.token_gen, 7); + assert!(claims.iat > 0); + assert!(claims.exp > claims.iat); +} + +#[tokio::test] +async fn session_token_admin_role() { + let jwt = jwt_service().await; + let token = jwt.sign(test_user(true), 0).await.unwrap(); + let claims = jwt.verify(&token).unwrap(); + + assert_eq!(claims.role, "admin"); +} + +#[tokio::test] +async fn session_token_has_kid_header() { + let jwt = jwt_service().await; + let token = jwt.sign(test_user(false), 0).await.unwrap(); + + // Decode the header (first segment) without verification. + let header = jsonwebtoken::decode_header(&token).unwrap(); + assert!(header.kid.is_some(), "JWT header should contain a `kid`"); + assert_eq!(header.alg, jsonwebtoken::Algorithm::RS256); +} + +#[tokio::test] +async fn session_token_rejected_with_email_verification_audience() { + let jwt = jwt_service().await; + let token = jwt.sign(test_user(false), 0).await.unwrap(); + + // Trying to verify a session token as an email-verification token + // should fail because the audience does not match. + let err = jwt.verify_email_token(&token).unwrap_err(); + assert_eq!(err, AuthError::TokenInvalid); +} + +// ── Email-verification tokens ──────────────────────────────────────── + +#[tokio::test] +async fn sign_and_verify_email_verification_token() { + let jwt = jwt_service().await; + let token = jwt.sign_email_verification(99).await.unwrap(); + let claims = jwt.verify_email_token(&token).unwrap(); + + assert_eq!(claims.sub, 99); +} + +#[tokio::test] +async fn email_token_contains_expected_claims() { + let jwt = jwt_service().await; + let token = jwt.sign_email_verification(99).await.unwrap(); + let claims = jwt.verify_email_token(&token).unwrap(); + + assert_eq!(claims.iss, "torrust-index"); + assert_eq!(claims.aud, "email-verification"); + assert!(claims.iat > 0); + assert!(claims.exp > claims.iat); +} + +#[tokio::test] +async fn email_token_rejected_with_session_audience() { + let jwt = jwt_service().await; + let token = jwt.sign_email_verification(99).await.unwrap(); + + // Trying to verify an email-verification token as a session token + // should fail because the audience does not match. + let err = jwt.verify(&token).unwrap_err(); + assert_eq!(err, AuthError::TokenInvalid); +} + +// ── Error paths ────────────────────────────────────────────────────── + +#[tokio::test] +async fn verify_rejects_garbage_token() { + let jwt = jwt_service().await; + let err = jwt.verify("not-a-jwt").unwrap_err(); + assert_eq!(err, AuthError::TokenInvalid); +} + +#[tokio::test] +async fn verify_rejects_tampered_token() { + let jwt = jwt_service().await; + let token = jwt.sign(test_user(false), 0).await.unwrap(); + + // Flip a character in the signature (last segment). + let mut chars: Vec = token.chars().collect(); + let last = chars.len() - 1; + chars[last] = if chars[last] == 'A' { 'B' } else { 'A' }; + let tampered: String = chars.into_iter().collect(); + + let err = jwt.verify(&tampered).unwrap_err(); + assert_eq!(err, AuthError::TokenInvalid); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 04696e9f7..fba5b4066 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -2,9 +2,11 @@ mod bootstrap; mod cache; mod config; mod errors; +mod jwt; mod mailer; mod models; mod services; mod tracker; mod ui; mod utils; +mod web; diff --git a/src/tests/web/auth.rs b/src/tests/web/auth.rs new file mode 100644 index 000000000..6fe04b2a1 --- /dev/null +++ b/src/tests/web/auth.rs @@ -0,0 +1,58 @@ +//! Crate tests for the `parse_token` and `BearerToken` boundary +//! logic (`src/web/api/server/v1/auth.rs` and +//! `src/web/api/server/v1/extractors/bearer_token.rs`). +//! +//! # Test index +//! +//! ## `parse_token` +//! +//! - [`parse_valid_bearer_token`] — extracts the token string from a +//! well-formed `Bearer ` header. +//! - [`parse_bearer_token_trims_whitespace`] — leading/trailing +//! whitespace around the token value is stripped. +//! - [`parse_rejects_empty_bearer`] — `"Bearer "` with no token → +//! `TokenInvalid`. +//! - [`parse_rejects_missing_bearer_prefix`] — a header without the +//! `Bearer` prefix → `TokenInvalid`. +//! - [`parse_rejects_non_ascii_header`] — non-UTF-8 header bytes → +//! `TokenInvalid`. + +use hyper::http::HeaderValue; + +use crate::errors::AuthError; +use crate::web::api::server::v1::auth::parse_token; + +#[test] +fn parse_valid_bearer_token() { + let header = HeaderValue::from_static("Bearer eyJhbGciOiJSUzI1NiJ9.test.sig"); + let token = parse_token(&header).unwrap(); + assert_eq!(token, "eyJhbGciOiJSUzI1NiJ9.test.sig"); +} + +#[test] +fn parse_bearer_token_trims_whitespace() { + let header = HeaderValue::from_static("Bearer my_token "); + let token = parse_token(&header).unwrap(); + assert_eq!(token, "my_token"); +} + +#[test] +fn parse_rejects_empty_bearer() { + let header = HeaderValue::from_static("Bearer "); + let err = parse_token(&header).unwrap_err(); + assert_eq!(err, AuthError::TokenInvalid); +} + +#[test] +fn parse_rejects_missing_bearer_prefix() { + let header = HeaderValue::from_static("Basic abc123"); + let err = parse_token(&header).unwrap_err(); + assert_eq!(err, AuthError::TokenInvalid); +} + +#[test] +fn parse_rejects_non_ascii_header() { + let header = HeaderValue::from_bytes(b"Bearer \xff\xfe").unwrap(); + let err = parse_token(&header).unwrap_err(); + assert_eq!(err, AuthError::TokenInvalid); +} diff --git a/src/tests/web/mod.rs b/src/tests/web/mod.rs new file mode 100644 index 000000000..12bc9de49 --- /dev/null +++ b/src/tests/web/mod.rs @@ -0,0 +1 @@ +mod auth; diff --git a/src/web/api/client/v1/contexts/settings/mod.rs b/src/web/api/client/v1/contexts/settings/mod.rs index 01a056b7f..a3229f261 100644 --- a/src/web/api/client/v1/contexts/settings/mod.rs +++ b/src/web/api/client/v1/contexts/settings/mod.rs @@ -49,7 +49,8 @@ pub struct Network { #[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] pub struct Auth { - pub user_claim_token_pepper: String, + pub private_key_path: Option, + pub public_key_path: Option, pub password_constraints: PasswordConstraints, } @@ -152,7 +153,8 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { Self { - user_claim_token_pepper: auth.user_claim_token_pepper.to_string(), + private_key_path: auth.private_key_path, + public_key_path: auth.public_key_path, password_constraints: auth.password_constraints.into(), } } diff --git a/src/web/api/server/v1/auth.rs b/src/web/api/server/v1/auth.rs index e7cafdbe8..711724c15 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -42,19 +42,48 @@ //! ```json //! { //! "data":{ -//! "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI", +//! "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImExYjJjM2Q0ZTVmNmE3YjgifQ.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImdlbiI6MH0.RS256-SIGNATURE", //! "username":"indexadmin", //! "admin":true //! } //! } //! ``` //! -//! **NOTICE**: The token is valid for 2 weeks (`1_209_600` seconds). After that, -//! you will have to renew the token. +//! The JWT is signed with RS256 (RSA + SHA-256). The payload contains +//! RFC 7519 registered claims plus advisory fields: //! -//! **NOTICE**: The token is associated with the user role. If you change the -//! user's role, you will have to log in again to get a new token with the new -//! role. +//! ```json +//! { +//! "sub": 1, +//! "iss": "torrust-index", +//! "aud": "session", +//! "iat": 1686215788, +//! "exp": 1687425388, +//! "role": "admin", +//! "username": "indexadmin", +//! "gen": 0 +//! } +//! ``` +//! +//! The `role` and `username` fields are **advisory only** — the +//! authoritative role is always re-checked from the database on each +//! authenticated request (see ADR-T-007 Phase 2). +//! +//! The `gen` field is the token-generation counter. When a user's +//! password changes, role changes, or the user is banned, the counter +//! is incremented and any token carrying an older `gen` value is +//! rejected. Validation is performed by +//! [`JsonWebToken::validate_session`](crate::jwt::JsonWebToken::validate_session) +//! (see ADR-T-007 Phases 4 & 7). +//! +//! **NOTICE**: The token lifetime is configurable via +//! `auth.session_token_lifetime_secs` (default: 2 weeks / `1_209_600` seconds). +//! After expiry you will have to renew the token. +//! +//! **NOTICE**: The token `role` is advisory. If the user's role changes in +//! the database, the new role takes effect immediately on the next request. +//! However, you may still want to log in again to get a token that reflects +//! the current role. //! //! ## Using the token //! @@ -65,7 +94,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImExYjJjM2Q0ZTVmNmE3YjgifQ.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImdlbiI6MH0.RS256-SIGNATURE" \ //! --request POST \ //! --data '{"name":"new category","icon":null}' \ //! http://127.0.0.1:3001/v1/category @@ -82,94 +111,76 @@ use std::sync::Arc; use hyper::http::HeaderValue; -use crate::common::AppData; +use crate::databases::database::Database; use crate::errors::AuthError; -use crate::models::user::{UserClaims, UserCompact, UserId}; -use crate::services::authentication::JsonWebToken; +use crate::jwt::{JsonWebToken, SessionClaims}; +use crate::models::user::{UserCompact, UserId}; use crate::web::api::server::v1::extractors::bearer_token::BearerToken; pub struct Authentication { json_web_token: Arc, + database: Arc>, } impl Authentication { #[must_use] - pub const fn new(json_web_token: Arc) -> Self { - Self { json_web_token } + pub fn new(json_web_token: Arc, database: Arc>) -> Self { + Self { + json_web_token, + database, + } } /// Create Json Web Token - pub async fn sign_jwt(&self, user: UserCompact) -> String { - self.json_web_token.sign(user).await - } - - /// Verify Json Web Token /// /// # Errors /// - /// This function will return an error if the JWT is not good or expired. - pub async fn verify_jwt(&self, token: &str) -> Result { - self.json_web_token.verify(token).await + /// Returns `AuthError::InternalServerError` if the token cannot be encoded. + pub async fn sign_jwt(&self, user: UserCompact, token_generation: u64) -> Result { + self.json_web_token.sign(user, token_generation).await } - /// Get logged-in user ID from bearer token + /// Verify Json Web Token /// /// # Errors /// - /// This function will return an error if it can get claims from the request - pub async fn get_user_id_from_bearer_token(&self, maybe_token: Option) -> Result { - let claims = self.get_claims_from_bearer_token(maybe_token).await?; - Ok(claims.user.user_id) + /// This function will return an error if the JWT is not good or expired. + pub fn verify_jwt(&self, token: &str) -> Result { + self.json_web_token.verify(token) } - /// Get Claims from bearer token + /// Get logged-in user ID from bearer token, validating the token + /// generation counter against the database. /// /// # Errors /// - /// This function will: - /// - /// - Return an `AuthError::TokenNotFound` if `HeaderValue` is `None`. - /// - Pass through the `AuthError::TokenInvalid` if unable to verify the JWT. - async fn get_claims_from_bearer_token(&self, maybe_token: Option) -> Result { - match maybe_token { - Some(token) => match self.verify_jwt(&token.value()).await { - Ok(claims) => Ok(claims), - Err(e) => Err(e), - }, - None => Err(AuthError::TokenNotFound), - } + /// This function will return an error if the JWT is invalid, expired, + /// or if the token's generation has been revoked. + pub async fn get_user_id_from_bearer_token(&self, token: BearerToken) -> Result { + let claims = self.json_web_token.validate_session(&**self.database, token.as_str()).await?; + Ok(claims.sub) } } /// Parses the token from the `Authorization` header. /// -/// # Panics -/// -/// This function will panic if the `Authorization` header is not a valid `String`. -pub fn parse_token(authorization: &HeaderValue) -> String { - let split: Vec<&str> = authorization - .to_str() - .expect("variable `auth` contains data that is not visible ASCII chars.") - .split("Bearer") - .collect(); - let token = split[1].trim(); - token.to_string() -} - -/// If the user is logged in, returns the user's ID. Otherwise, returns `None`. -/// /// # Errors /// -/// It returns an error if we cannot get the user from the bearer token. -pub async fn get_optional_logged_in_user( - maybe_bearer_token: Option, - app_data: Arc, -) -> Result, AuthError> { - match maybe_bearer_token { - Some(bearer_token) => match app_data.auth.get_user_id_from_bearer_token(Some(bearer_token)).await { - Ok(user_id) => Ok(Some(user_id)), - Err(error) => Err(error), - }, - None => Ok(None), +/// Returns `AuthError::TokenInvalid` if the header value is not valid +/// ASCII or does not contain a `Bearer ` pair. The scheme name +/// is matched case-insensitively per RFC 7235 §2.1. +pub fn parse_token(authorization: &HeaderValue) -> Result { + let header_str = authorization.to_str().map_err(|_| AuthError::TokenInvalid)?; + + let token = header_str + .get(7..) + .filter(|_| header_str[..7].eq_ignore_ascii_case("bearer ")) + .ok_or(AuthError::TokenInvalid)? + .trim(); + + if token.is_empty() { + return Err(AuthError::TokenInvalid); } + + Ok(token.to_string()) } diff --git a/src/web/api/server/v1/contexts/category/mod.rs b/src/web/api/server/v1/contexts/category/mod.rs index c6ed8a712..79b2efd9f 100644 --- a/src/web/api/server/v1/contexts/category/mod.rs +++ b/src/web/api/server/v1/contexts/category/mod.rs @@ -81,7 +81,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request POST \ //! --data '{"name":"new category","icon":null}' \ //! http://127.0.0.1:3001/v1/category @@ -119,7 +119,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request DELETE \ //! --data '{"name":"new category","icon":null}' \ //! http://127.0.0.1:3001/v1/category diff --git a/src/web/api/server/v1/contexts/proxy/mod.rs b/src/web/api/server/v1/contexts/proxy/mod.rs index 8c3608d91..13facadc7 100644 --- a/src/web/api/server/v1/contexts/proxy/mod.rs +++ b/src/web/api/server/v1/contexts/proxy/mod.rs @@ -47,7 +47,7 @@ //! //! ```bash //! curl \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --header "cache-control: no-cache" \ //! --header "pragma: no-cache" \ //! --output mandelbrotset.jpg \ diff --git a/src/web/api/server/v1/contexts/settings/mod.rs b/src/web/api/server/v1/contexts/settings/mod.rs index a920d9f8b..c8c974fee 100644 --- a/src/web/api/server/v1/contexts/settings/mod.rs +++ b/src/web/api/server/v1/contexts/settings/mod.rs @@ -19,7 +19,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request GET \ //! "http://127.0.0.1:3001/v1/settings" //! ``` @@ -54,7 +54,12 @@ //! "tsl": null //! }, //! "auth": { -//! "user_claim_token_pepper": "***", +//! "private_key_pem": null, +//! "public_key_pem": null, +//! "private_key_path": null, +//! "public_key_path": null, +//! "session_token_lifetime_secs": 1209600, +//! "email_verification_token_lifetime_secs": 315569260, //! "password_constraints": { //! "max_password_length": 64, //! "min_password_length": 6 diff --git a/src/web/api/server/v1/contexts/tag/mod.rs b/src/web/api/server/v1/contexts/tag/mod.rs index eb4dd68db..76521e034 100644 --- a/src/web/api/server/v1/contexts/tag/mod.rs +++ b/src/web/api/server/v1/contexts/tag/mod.rs @@ -61,7 +61,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request POST \ //! --data '{"name":"new tag"}' \ //! http://127.0.0.1:3001/v1/tag @@ -98,7 +98,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request DELETE \ //! --data '{"tag_id":1}' \ //! http://127.0.0.1:3001/v1/tag diff --git a/src/web/api/server/v1/contexts/torrent/mod.rs b/src/web/api/server/v1/contexts/torrent/mod.rs index 644da338b..97802ddf6 100644 --- a/src/web/api/server/v1/contexts/torrent/mod.rs +++ b/src/web/api/server/v1/contexts/torrent/mod.rs @@ -48,7 +48,7 @@ //! ```bash //! curl \ //! --header "Content-Type: multipart/form-data" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request POST \ //! --form "title=MandelbrotSet" \ //! --form "description=MandelbrotSet image" \ @@ -87,7 +87,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/x-bittorrent" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --output mandelbrot_2048x2048_infohash_v1.png.torrent \ //! "http://127.0.0.1:3001/v1/torrent/download/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` @@ -129,7 +129,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request GET \ //! "http://127.0.0.1:3001/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` @@ -215,7 +215,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request GET \ //! "http://127.0.0.1:3001/v1/torrents" //! ``` @@ -279,7 +279,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request PUT \ //! --data '{"title":"MandelbrotSet", "description":"MandelbrotSet image"}' \ //! "http://127.0.0.1:3001/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" @@ -336,7 +336,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request DELETE \ //! "http://127.0.0.1:3001/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index 4a67c4068..31f8a70b3 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -71,7 +71,7 @@ pub async fn email_verification_handler(State(app_data): State>, Pa /// It returns an error if: /// /// - Unable to verify the supplied payload as a valid JWT. -/// - The JWT is not invalid or expired. +/// - The JWT is invalid or expired. #[allow(clippy::unused_async)] pub async fn login_handler( State(app_data): State>, @@ -94,13 +94,17 @@ pub async fn login_handler( /// It returns an error if: /// /// - Unable to verify the supplied payload as a valid JWT. -/// - The JWT is not invalid or expired. -#[allow(clippy::unused_async)] +/// - The JWT is invalid or expired. +/// - The token's generation has been revoked. pub async fn verify_token_handler( State(app_data): State>, extract::Json(token): extract::Json, ) -> Response { - match app_data.json_web_token.verify(&token.token).await { + match app_data + .json_web_token + .validate_session(&**app_data.database, &token.token) + .await + { Ok(_) => axum::Json(OkResponseData { data: "Token is valid.".to_string(), }) @@ -119,7 +123,7 @@ pub struct UsernameParam(pub String); /// It returns an error if: /// /// - Unable to parse the supplied payload as a valid JWT. -/// - The JWT is not invalid or expired. +/// - The JWT is invalid or expired. #[allow(clippy::unused_async)] pub async fn renew_token_handler( State(app_data): State>, diff --git a/src/web/api/server/v1/contexts/user/mod.rs b/src/web/api/server/v1/contexts/user/mod.rs index 1c1461211..4cc25770a 100644 --- a/src/web/api/server/v1/contexts/user/mod.rs +++ b/src/web/api/server/v1/contexts/user/mod.rs @@ -45,7 +45,9 @@ //! //! ```toml //! [auth] -//! user_claim_token_pepper = "MaxVerstappenWC2021" +//! # Optional: supply your own RSA key pair for persistent sessions. +//! # private_key_path = "/path/to/private.pem" +//! # public_key_path = "/path/to/public.pem" //! ``` //! //! Refer to the [`RegistrationForm`](crate::web::api::server::v1::contexts::user::forms::RegistrationForm) @@ -121,7 +123,7 @@ //! //! Name | Type | Description | Required | Example //! ---|---|---|---|--- -//! `token` | `String` | The token you want to verify | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` +//! `token` | `String` | The token you want to verify | Yes | `` //! //! Refer to the [`JsonWebToken`](crate::web::api::server::v1::contexts::user::forms::JsonWebToken) //! struct for more information about the token. @@ -132,7 +134,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request POST \ -//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ +//! --data '{"token":""}' \ //! http://127.0.0.1:3001/v1/user/token/verify //! ``` //! @@ -167,7 +169,7 @@ //! //! Name | Type | Description | Required | Example //! ---|---|---|---|--- -//! `token` | `String` | The current valid token | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` +//! `token` | `String` | The current valid token | Yes | `` //! //! Refer to the [`JsonWebToken`](crate::web::api::server::v1::contexts::user::forms::JsonWebToken) //! struct for more information about the token. @@ -178,7 +180,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request POST \ -//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ +//! --data '{"token":""}' \ //! http://127.0.0.1:3001/v1/user/token/renew //! ``` //! @@ -189,7 +191,7 @@ //! ```json //! { //! "data": { -//! "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI", +//! "token": "", //! "username": "indexadmin", //! "admin": true //! } @@ -223,7 +225,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer " \ //! --request DELETE \ //! http://127.0.0.1:3001/v1/user/ban/indexadmin //! ``` diff --git a/src/web/api/server/v1/extractors/bearer_token.rs b/src/web/api/server/v1/extractors/bearer_token.rs index fa0b0c205..0bf06ba8e 100644 --- a/src/web/api/server/v1/extractors/bearer_token.rs +++ b/src/web/api/server/v1/extractors/bearer_token.rs @@ -1,35 +1,47 @@ use axum::extract::FromRequestParts; use axum::http::request::Parts; -use axum::response::Response; -use serde::Deserialize; +use axum::response::{IntoResponse, Response}; +use crate::errors::AuthError; use crate::web::api::server::v1::auth::parse_token; -pub struct Extract(pub Option); - -#[derive(Deserialize, Debug)] +/// A validated bearer token extracted from the `Authorization` header. +/// +/// Implements [`FromRequestParts`] and **rejects** the request when +/// the header is missing (`AuthError::TokenNotFound`) or malformed +/// (`AuthError::TokenInvalid`). +/// +/// For endpoints where authentication is optional, use +/// `Option` — Axum will yield `None` when extraction +/// fails instead of rejecting the request. +/// +/// This addresses Problem #11 from ADR-T-007: the extractor now +/// catches missing/invalid tokens at the boundary rather than +/// deferring the check downstream. +#[derive(Debug)] pub struct BearerToken(String); impl BearerToken { #[must_use] - pub fn value(&self) -> String { - self.0.clone() + pub fn as_str(&self) -> &str { + &self.0 } } -impl FromRequestParts for Extract +impl FromRequestParts for BearerToken where S: Send + Sync, { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let header = parts.headers.get("Authorization"); + let header_value = parts + .headers + .get("Authorization") + .ok_or_else(|| AuthError::TokenNotFound.into_response())?; + + let token = parse_token(header_value).map_err(IntoResponse::into_response)?; - #[allow(clippy::option_if_let_else)] - match header { - Some(header_value) => Ok(Self(Some(BearerToken(parse_token(header_value))))), - None => Ok(Self(None)), - } + Ok(Self(token)) } } diff --git a/src/web/api/server/v1/extractors/optional_user_id.rs b/src/web/api/server/v1/extractors/optional_user_id.rs index e7ed657cf..abb347a88 100644 --- a/src/web/api/server/v1/extractors/optional_user_id.rs +++ b/src/web/api/server/v1/extractors/optional_user_id.rs @@ -6,7 +6,7 @@ use axum::response::Response; use crate::common::AppData; use crate::models::user::UserId; -use crate::web::api::server::v1::extractors::bearer_token; +use crate::web::api::server::v1::extractors::bearer_token::BearerToken; pub struct ExtractOptionalLoggedInUser(pub Option); @@ -18,20 +18,12 @@ where type Rejection = Response; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let bearer_token = match bearer_token::Extract::from_request_parts(parts, state).await { - Ok(bearer_token) => bearer_token.0, - Err(_) => None, + let Ok(token) = BearerToken::from_request_parts(parts, state).await else { + return Ok(Self(None)); }; - //Extracts the app state let app_data = Arc::from_ref(state); - #[allow(clippy::option_if_let_else)] - let result = match app_data.auth.get_user_id_from_bearer_token(bearer_token).await { - Ok(user_id) => Ok(Self(Some(user_id))), - Err(_) => Ok(Self(None)), - }; - - result + Ok(Self(app_data.auth.get_user_id_from_bearer_token(token).await.ok())) } } diff --git a/src/web/api/server/v1/extractors/user_id.rs b/src/web/api/server/v1/extractors/user_id.rs index a0c203a7e..14b718b5a 100644 --- a/src/web/api/server/v1/extractors/user_id.rs +++ b/src/web/api/server/v1/extractors/user_id.rs @@ -4,10 +4,9 @@ use axum::extract::{FromRef, FromRequestParts}; use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; -use super::bearer_token; use crate::common::AppData; -use crate::errors::AuthError; use crate::models::user::UserId; +use crate::web::api::server::v1::extractors::bearer_token::BearerToken; pub struct ExtractLoggedInUser(pub UserId); @@ -19,20 +18,15 @@ where type Rejection = Response; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let maybe_bearer_token = match bearer_token::Extract::from_request_parts(parts, state).await { - Ok(maybe_bearer_token) => maybe_bearer_token.0, - Err(_) => return Err(AuthError::TokenNotFound.into_response()), - }; + let token = BearerToken::from_request_parts(parts, state).await?; - //Extracts the app state let app_data = Arc::from_ref(state); - #[allow(clippy::option_if_let_else)] - let result = match app_data.auth.get_user_id_from_bearer_token(maybe_bearer_token).await { - Ok(user_id) => Ok(Self(user_id)), - Err(_) => Err(AuthError::LoggedInUserNotFound.into_response()), - }; - - result + app_data + .auth + .get_user_id_from_bearer_token(token) + .await + .map(Self) + .map_err(IntoResponse::into_response) } } diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index d3c563d27..8e199344c 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -55,7 +55,8 @@ pub struct Network { #[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] pub struct Auth { - pub user_claim_token_pepper: String, + pub private_key_path: Option, + pub public_key_path: Option, pub password_constraints: PasswordConstraints, } @@ -179,7 +180,8 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { Self { - user_claim_token_pepper: auth.user_claim_token_pepper.to_string(), + private_key_path: auth.private_key_path, + public_key_path: auth.public_key_path, password_constraints: auth.password_constraints.into(), } } diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 00bd97917..3fbd92870 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -114,7 +114,12 @@ impl TestEnv { "***".clone_into(&mut settings.mail.smtp.credentials.password); - "***".clone_into(&mut settings.auth.user_claim_token_pepper); + if let Some(private_key_path) = settings.auth.private_key_path.as_mut() { + "***-redacted***".clone_into(private_key_path); + } + if let Some(public_key_path) = settings.auth.public_key_path.as_mut() { + "***-redacted***".clone_into(public_key_path); + } Some(settings) } diff --git a/tests/fixtures/default_configuration.toml b/tests/fixtures/default_configuration.toml index 30302a8c4..b34b54d38 100644 --- a/tests/fixtures/default_configuration.toml +++ b/tests/fixtures/default_configuration.toml @@ -44,7 +44,6 @@ url = "udp://localhost:6969" bind_address = "0.0.0.0:3001" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" [auth.password_constraints] max_password_length = 64