From b6f51c3071cbe8d9db6d870c0681ad36d79d3ead Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Tue, 14 Apr 2026 21:24:36 +0200 Subject: [PATCH 01/10] refactor!: centralise JWT handling into dedicated module (ADR-T-007 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `src/jwt.rs` — a single module that owns all `jsonwebtoken` usage (key loading, signing, verification, algorithm selection). Key changes: - Move `JsonWebToken` service from `services::authentication` to `jwt` module, covering both session and email-verification tokens. - Move `UserClaims` from `models::user` and `VerifyClaims` from `mailer` into `jwt`, co-located with the code that produces and consumes them. - Rename config field `user_claim_token_pepper` → `jwt_signing_secret` (with legacy alias accepted during config loading). - Add `session_token_lifetime_secs` and `email_verification_token_lifetime_secs` config options, replacing the hard-coded magic numbers. - Make `sign()` return `Result` instead of panicking on encoding failure. - Make `parse_token()` return `Result` instead of panicking on malformed `Authorization` headers. - Propagate the `JsonWebToken` service via `Arc` into `mailer::Service` and `user::RegistrationService`, removing their direct `jsonwebtoken` crate dependencies. BREAKING CHANGE: The config key `auth.user_claim_token_pepper` is renamed to `auth.jwt_signing_secret`. The old key is still accepted at startup but the canonical name has changed. The settings JSON response now includes `jwt_signing_secret`, `session_token_lifetime_secs`, and `email_verification_token_lifetime_secs` fields. --- .env.local | 2 +- README.md | 5 +- adr/007-jwt-system-refactor.md | 442 ++++++++++++++++++ compose.yaml | 2 +- .../e2e/sqlite/mode/private/e2e-env-up.sh | 2 +- .../e2e/sqlite/mode/public/e2e-env-up.sh | 2 +- docs/containers.md | 4 +- .../default/config/index.container.mysql.toml | 2 +- .../config/index.container.sqlite3.toml | 2 +- .../config/index.development.sqlite3.toml | 2 +- .../index.private.e2e.container.sqlite3.toml | 2 +- .../index.public.e2e.container.mysql.toml | 2 +- .../index.public.e2e.container.sqlite3.toml | 2 +- src/app.rs | 3 +- src/config/mod.rs | 19 +- src/config/v2/auth.rs | 55 ++- src/config/v2/mod.rs | 4 +- src/jwt.rs | 144 ++++++ src/lib.rs | 3 +- src/mailer.rs | 47 +- src/models/user.rs | 7 - src/services/authentication.rs | 63 +-- src/services/user.rs | 25 +- src/tests/config/mod.rs | 13 +- src/tests/config/v2/auth.rs | 4 +- .../api/client/v1/contexts/settings/mod.rs | 4 +- src/web/api/server/v1/auth.rs | 38 +- .../api/server/v1/contexts/settings/mod.rs | 4 +- src/web/api/server/v1/contexts/user/mod.rs | 2 +- .../api/server/v1/extractors/bearer_token.rs | 2 +- tests/common/contexts/settings/mod.rs | 4 +- tests/e2e/environment.rs | 2 +- tests/fixtures/default_configuration.toml | 2 +- 33 files changed, 734 insertions(+), 182 deletions(-) create mode 100644 adr/007-jwt-system-refactor.md create mode 100644 src/jwt.rs diff --git a/.env.local b/.env.local index 1260d0f08..c21465f0d 100644 --- a/.env.local +++ b/.env.local @@ -1,6 +1,6 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc TORRUST_INDEX_CONFIG_TOML= -TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY=MaxVerstappenWC2021 +TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET=MaxVerstappenWC2021 USER_ID=1000 TORRUST_TRACKER_CONFIG_TOML= TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=Sqlite3 diff --git a/README.md b/README.md index 27e4e9c4a..2245a2377 100644 --- a/README.md +++ b/README.md @@ -95,14 +95,14 @@ 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:_ +- The `tracker_api_token` and the `index_auth_jwt_signing_secret` by using environmental variables:_ ```sh # Please use the secret that you generated for the torrust-tracker configuration. # Override secret in configuration using an environmental variable 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" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET="MaxVerstappenWC2021" \ cargo run ``` @@ -127,6 +127,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`, rename `ClaimTokenPepper` → `JwtSigningSecret`, make token lifetimes configurable, and fix panics in token parsing. ## Contributing diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md new file mode 100644 index 000000000..342713f3b --- /dev/null +++ b/adr/007-jwt-system-refactor.md @@ -0,0 +1,442 @@ +# ADR-T-007: Refactor the JWT System + +**Status:** Phase 1 implemented +**Date:** 2026-04-14 + +## Context + +The JWT (JSON Web Token) authentication system has grown organically +and currently exhibits several structural and security problems. +This ADR catalogues the issues and presents options for a +comprehensive refactor. + +### Current Architecture + +JWT handling is spread across four locations with two distinct +claim types: + +| Location | Purpose | Claim type | +|---|---|---| +| `services::authentication::JsonWebToken` | Sign/verify session tokens | `UserClaims` | +| `web::api::server::v1::auth::Authentication` | Thin 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` | + +Both token types share the same HMAC-HS256 signing secret +(`auth.user_claim_token_pepper`). + +### Identified Problems + +1. **Single shared secret for all token purposes.** + Session JWTs and email-verification JWTs share the same + HMAC secret (`user_claim_token_pepper`). A leaked session + token secret also compromises email-verification tokens, + and vice-versa. There is no audience/purpose separation at + the key level. + +2. **HMAC-HS256 with a text "pepper" as the key.** + The signing key is an arbitrary human-readable string + (default: `"MaxVerstappenWC2021"`), used directly via + `EncodingKey::from_secret(key.as_bytes())`. There is no + minimum entropy requirement, no key derivation, and no + support for asymmetric algorithms. HMAC-HS256 with a + low-entropy secret is vulnerable to offline brute-force + attacks on captured tokens. + +3. **No `iss`, `aud`, or `sub` claims on session tokens.** + `UserClaims` contains only `{ user: UserCompact, exp: u64 }`. + It has no `iss` (issuer), `aud` (audience), `sub` (subject), + or `iat` (issued-at) fields. RFC 7519 recommends these + registered claims for interoperability and security. By + contrast, `VerifyClaims` in the mailer *does* set `iss` and + `sub`, but not `aud`. + +4. **User data embedded verbatim in the JWT payload.** + `UserClaims` embeds the full `UserCompact` struct + (`user_id`, `username`, `administrator`) in the payload. + This means the `administrator` flag is trusted from the token + rather than re-checked from the database at each request. If + a user's role changes, existing tokens carry stale privileges + until they expire. Role escalation tokens remain valid for + the full two-week window. + +5. **Hard-coded expiration durations.** + Session tokens expire in `1_209_600` seconds (2 weeks) with a + `// todo` comment acknowledging this should be configurable. + Email-verification tokens expire in `315_569_260` seconds + (~10 years). The renewal threshold is a hard-coded + `ONE_WEEK_IN_SECONDS`. None of these are configurable. + +6. **Redundant / manual expiration checking.** + `JsonWebToken::verify` passes `Validation::new(Algorithm::HS256)` + to `jsonwebtoken::decode`, which already validates `exp` by + default, yet the code performs an *additional* manual + `if token_data.claims.exp < clock::now()` check. These two + checks may disagree in edge cases (clock skew handling + differs). + +7. **`.unwrap()` when signing tokens.** + `mailer::get_verification_url` calls `encode(...).unwrap()`. + While `JsonWebToken::sign` uses `.expect()` with a message, + both paths will panic at runtime if encoding fails, rather + than returning an error through the service layer. + +8. **`parse_token` panics on malformed headers.** + `parse_token` calls `.expect()` on `to_str()` and blindly + indexes into `split[1]`. A malformed `Authorization` header + will panic the request handler. + +9. **No token revocation mechanism.** + There is no blacklist, version counter, or server-side session + store. Once a JWT is signed, it is valid until `exp`. Password + changes, role changes, and bans do not invalidate outstanding + tokens. + +10. **Scattered `jsonwebtoken` usage — no single module.** + The `jsonwebtoken` crate is imported directly in three files + (`services/authentication.rs`, `mailer.rs`, `services/user.rs`). + There is no centralised JWT module that owns signing, verification, + key management, and algorithm configuration. Changing the algorithm + or key format requires touching multiple files. + +11. **`BearerToken` extractor returns `Ok(None)` on missing header.** + The Axum extractor never rejects a request for a missing + `Authorization` header — it returns `Ok(Extract(None))` and + defers the check downstream. This means every handler that + requires authentication must remember to check for `None` + and return `TokenNotFound` itself. + +12. **`ClaimTokenPepper` naming is misleading.** + In cryptography a "pepper" is a secret added to a password + hash. Here it is used as an HMAC signing key, which is a + fundamentally different concept. The name + `user_claim_token_pepper` / `ClaimTokenPepper` obscures the + actual role of the value. + +## Options + +### Option A — Incremental Cleanup (minimal scope) + +Fix the most acute issues without changing the token format or +breaking API compatibility. + +**Changes:** + +- Extract a `jwt` module (`src/jwt.rs` or `src/jwt/mod.rs`) that + centralises all `jsonwebtoken` usage: key loading, `sign`, + `verify`, algorithm config. +- Move `VerifyClaims` into the new module alongside `UserClaims`. +- Make expiration durations configurable in `Auth` config + (`session_token_lifetime_seconds`, + `email_verification_token_lifetime_seconds`). +- Remove the redundant manual `exp` check — rely on the library's + built-in validation. +- Replace `.unwrap()` / `.expect()` with `Result` propagation. +- Fix `parse_token` to return `Result` instead of panicking. +- Rename `ClaimTokenPepper` → `JwtSigningSecret` (or similar). +- Add `iss` and `sub` claims to `UserClaims`. + +**Pros:** +- Small diff, low risk, no breaking API change. +- All existing tokens remain valid (backward-compatible). + +**Cons:** +- Does not address the stale-role-in-token problem (#4). +- Does not address single-secret-for-all-purposes (#1). +- Does not add revocation (#9). +- HMAC-HS256 with a low-entropy secret remains (#2). + +--- + +### Option B — Proper Claim Design + Per-Purpose Keys + +Redesign the JWT claims to follow RFC 7519 best practices and +introduce separate signing keys per token purpose. + +**Changes (includes all of Option A, plus):** + +- Split the config secret into two independent keys: + `auth.session_signing_key` and `auth.email_verification_signing_key`. +- Redesign `UserClaims` to standard registered claims: + ```rust + struct SessionClaims { + sub: UserId, // subject = user ID + iss: String, // "torrust-index" + aud: String, // "session" + iat: u64, + exp: u64, + role: Role, // admin / user + username: String, // convenience, non-authoritative + } + ``` +- Redesign `VerifyClaims` similarly with `aud: "email-verification"`. +- Re-validate the `role` / `administrator` flag from the database + on every authenticated request (or cache with a short TTL) so + the token role is advisory only. +- Enforce a minimum secret length at config validation time. + +**Pros:** +- Purpose-separated keys: compromising one does not affect the other. +- Stale roles no longer grant elevated privileges. +- Standards-compliant claims improve interoperability. + +**Cons:** +- **Breaking change** — existing session tokens become invalid on + deploy (users must re-login). +- Config migration required (new key names). +- Re-checking the user role on every request adds a database + round-trip (can be mitigated with a short-lived cache). + +--- + +### Option C — Asymmetric Signing (RS256 / EdDSA) + +Move from symmetric HMAC to an asymmetric algorithm. + +**Changes (includes all of Option B, plus):** + +- Replace `HS256` with `RS256` or `EdDSA`. +- Store a private key (PEM / PKCS#8) for signing and a + corresponding public key for verification. +- Config provides a `auth.private_key_path` and + `auth.public_key_path` (or inline PEM via env var). +- Only the signing service needs the private key; the verification + layer (and potentially external services) only need the public + key. +- Supports future use-cases like external microservices verifying + tokens without sharing a secret, or JWKS endpoint publishing. + +**Pros:** +- Strongest security posture — no shared secret. +- Enables zero-trust verification by third-party services. +- Aligns with modern OAuth 2.0 / OIDC practices. +- Supports key rotation via JWKS-style `kid` header. + +**Cons:** +- Significantly higher complexity: key generation, storage, rotation. +- Larger tokens (RSA signatures are ~256 bytes vs. 32 for HMAC). + EdDSA mitigates this (~64 bytes). +- Operational burden: deployers must generate and manage key pairs. +- Breaking change — same token-invalidation concern as Option B. +- `simple_asn1` / `time` pin issues (see ADR-T-005) may constrain + which `jsonwebtoken` features can be enabled. + +--- + +### Option D — Move to Opaque Session Tokens + Server-Side Store + +Replace JWTs entirely with opaque session tokens backed by a +server-side session store. + +**Changes:** + +- Generate a cryptographically random opaque token on login + (e.g., 256-bit via `rand`). +- Store a session record (token hash, user ID, expiry, role) in a + new `torrust_sessions` database table (or Redis / in-memory cache). +- On each request, look up the token hash in the store, reject if + absent or expired. +- Email-verification tokens can remain as purpose-specific JWTs + (short-lived, no session semantics) or also become opaque + + stored. +- Remove the `jsonwebtoken` dependency entirely (or keep it only + for email-verification links). + +**Pros:** +- Instant revocation — delete the row, token is dead. +- No stale-role problem — role is always read from the store/DB. +- No secret-key management for session tokens. +- Eliminates all JWT-specific bugs (claim design, algorithm + confusion, etc.). + +**Cons:** +- Every authenticated request requires a store lookup (DB or cache). +- New infrastructure dependency if using Redis; new migration if + using the DB. +- Loses the statelessness benefit of JWTs. +- Larger scope — session management, garbage collection of expired + rows, etc. +- Breaking change for any client that currently introspects the + JWT payload. + +--- + +### Option E — Hybrid (JWT + Server-Side Revocation List) + +Keep JWTs for their stateless benefits but add a lightweight +server-side mechanism for revocation. + +**Changes (includes all of Option B, plus):** + +- Add a `token_generation` (or `jwt_epoch`) integer column to the + `torrust_users` table. Increment it on password change, role + change, or ban. +- Include `gen: u64` (the user's `token_generation` at sign time) + in the JWT claims. +- On verification, compare the token's `gen` to the current + database value; reject if stale. +- Optionally: maintain a small in-memory + `HashMap` cache with a TTL of a few + seconds, so the DB is not hit on every request. + +**Pros:** +- Retains stateless JWT benefits for the common (non-revoked) case. +- Revocation is near-instant (one DB update per user). +- Smaller scope than full session-store migration. +- Compatible with either symmetric or asymmetric signing. + +**Cons:** +- Still requires a DB/cache lookup per request (though cacheable). +- More complex than pure JWT or pure server-side sessions. +- Breaking change alongside Option B's claim redesign. + +## Decision + +**Option C — Asymmetric Signing with RS256**, implemented as a +phased rollout that subsumes Options A and B. + +### Why Option C + +1. **Dependency already supports it.** `jsonwebtoken 10.3.0` with + `rust_crypto` already enables `rsa`, `pem`, `sha2`, and + `use_pem`. No new crates, no feature-flag changes, no pin + concerns (the `simple_asn1`/`time` pin issues from ADR-T-003 + were resolved in ADR-T-005). + +2. **Strongest security posture.** Asymmetric signing eliminates + the shared-secret problem entirely. Only the signing service + holds the private key; verification requires only the public + key. This is a strict improvement over HS256 with a low-entropy + pepper. + +3. **Future-proof.** Enables external microservices (e.g., a + tracker, a frontend SSR server) to verify tokens without + sharing a secret. A JWKS endpoint or `kid` header can be added + later for key rotation without protocol changes. + +4. **Subsumes Options A and B.** The phased plan below delivers + all of Option A's cleanup and Option B's claim redesign as + prerequisite steps before switching the algorithm. + +### Why RS256 (not EdDSA) + +- RS256 (`RSASSA-PKCS1-v1_5 + SHA-256`) is the most widely + supported JWT algorithm across languages, libraries, and cloud + services. Every JWT implementation is required to support it + (RFC 7518 §3.1). +- EdDSA (Ed25519) produces smaller signatures (~64 bytes vs. + ~256 bytes for RS256), but the size difference is negligible + for authentication tokens transmitted once per request in an + HTTP header. +- RS256 key generation and management are well-understood + operationally (`openssl genrsa`). +- If EdDSA is desired in the future, the centralised `jwt` module + makes the algorithm a single-point change. + +### Why not Options D or E + +- **Option D (opaque tokens):** Adds a mandatory server-side store + (database table or Redis) on every request path. The project + does not currently need instant revocation badly enough to + justify the infrastructure cost and loss of statelessness. +- **Option E (hybrid revocation):** The `token_generation` column + approach is elegant but adds complexity that can be layered on + later without changing the token format. It remains a valid + follow-up if revocation becomes a priority. + +### Implementation Phases + +#### Phase 1 — Structural Cleanup (Option A scope) ✅ Implemented + +- ✅ Extract a `src/jwt.rs` module that centralises all + `jsonwebtoken` usage: key loading, `sign`, `verify`, algorithm + configuration. +- ✅ Move `UserClaims` and `VerifyClaims` into the new module. +- ✅ Replace `.unwrap()` / `.expect()` with `Result` propagation. +- ✅ Fix `parse_token` to return `Result` instead of panicking. +- ✅ Remove the redundant manual `exp` check (the library's + `Validation` already handles it). +- ✅ Rename `ClaimTokenPepper` → `JwtSigningSecret` + throughout config and code. +- ✅ Make expiration durations configurable: + `session_token_lifetime_secs`, + `email_verification_token_lifetime_secs`. + +#### Phase 2 — Claim Redesign + Per-Purpose Keys (Option B scope) + +- Redesign `UserClaims` → `SessionClaims`: + ```rust + struct SessionClaims { + sub: UserId, // subject = user ID + iss: String, // "torrust-index" + aud: String, // "session" + iat: u64, + exp: u64, + role: Role, // admin | user (advisory only) + username: String, // convenience, non-authoritative + } + ``` +- Redesign `VerifyClaims` with `aud: "email-verification"`. +- Split config into two independent keys: + `auth.session_signing_key` and + `auth.email_verification_signing_key`. +- Re-validate the user's role from the database on every + authenticated request (with a short-lived cache) so the token + role is advisory only. +- Enforce a minimum secret length at config validation time. +- **Breaking change:** existing HS256 tokens are invalidated; + users must re-login. + +#### Phase 3 — RS256 Asymmetric Signing (Option C scope) + +- Replace `HS256` with `RS256` (`Algorithm::RS256`). +- Config provides: + - `auth.private_key_path` (PEM / PKCS#8) for signing. + - `auth.public_key_path` for verification. + - Alternatively, inline PEM via environment variable. +- Generate a default development key pair on first run (with a + loud warning) so the zero-config experience is preserved for + local development. +- Use `EncodingKey::from_rsa_pem` / `DecodingKey::from_rsa_pem`. +- Only the signing service loads the private key; the + verification path uses the public key. +- Add a `kid` (Key ID) field to the JWT header to support future + key rotation. + +#### Future — Optional Revocation (Option E scope) + +- Add a `token_generation` column to `torrust_users`. +- Include `gen` in `SessionClaims`; reject stale generations on + verify. +- Increment generation on password change, role change, or ban. +- This phase is independent and can be shipped whenever revocation + becomes a priority. + +### Configuration Migration + +Deployers upgrading across Phase 2 / Phase 3 must: + +1. Generate an RSA key pair (e.g., + `openssl genrsa -out private.pem 2048` and + `openssl rsa -in private.pem -pubout -out public.pem`). +2. Update the config to reference the key paths (or set env vars). +3. Accept that existing sessions will be invalidated (users + re-login once). + +A migration guide will accompany the release that ships Phase 3. + +## Consequences + +- Existing user sessions **will be invalidated** when Phase 2 + ships (claim format change) and again if key material changes + in Phase 3. Users must re-login. +- Deployers must generate and manage an RSA key pair (Phase 3). + A development-mode auto-generated key reduces friction for + local setups. +- Token revocation is **not** included in the initial scope but + the architecture cleanly supports adding it later (Phase 4 / + Option E). +- The centralised `jwt` module makes future algorithm changes + (e.g., migrating to EdDSA) a localised, single-module change. +- External services can verify tokens using only the public key, + enabling zero-trust verification without secret sharing. diff --git a/compose.yaml b/compose.yaml index 3f63dde5f..df9f8a127 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,7 +13,7 @@ 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} + - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET:-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..661889af9 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,7 @@ 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_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET="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..ebe444606 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,7 @@ 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_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET="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/docs/containers.md b/docs/containers.md index 5039d363e..6cbf4864b 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -149,7 +149,7 @@ 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__JWT_SIGNING_SECRET` - Override of the auth JWT signing secret. If set, this value overrides any value set in the config. - `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`). @@ -202,7 +202,7 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## Run Torrust Index Container Image docker run -it \ --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ - --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SECRET_KEY="MaxVerstappenWC2021" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET="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/share/default/config/index.container.mysql.toml b/share/default/config/index.container.mysql.toml index 9bc28f58b..34384c579 100644 --- a/share/default/config/index.container.mysql.toml +++ b/share/default/config/index.container.mysql.toml @@ -15,7 +15,7 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +jwt_signing_secret = "MaxVerstappenWC2021" [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..60c8c3f7a 100644 --- a/share/default/config/index.container.sqlite3.toml +++ b/share/default/config/index.container.sqlite3.toml @@ -15,7 +15,7 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +jwt_signing_secret = "MaxVerstappenWC2021" [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..727ca8937 100644 --- a/share/default/config/index.development.sqlite3.toml +++ b/share/default/config/index.development.sqlite3.toml @@ -17,7 +17,7 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +jwt_signing_secret = "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..2b23fbb2c 100644 --- a/share/default/config/index.private.e2e.container.sqlite3.toml +++ b/share/default/config/index.private.e2e.container.sqlite3.toml @@ -19,7 +19,7 @@ token = "MyAccessToken" url = "http://tracker:7070" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +jwt_signing_secret = "MaxVerstappenWC2021" [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..e81761b28 100644 --- a/share/default/config/index.public.e2e.container.mysql.toml +++ b/share/default/config/index.public.e2e.container.mysql.toml @@ -17,7 +17,7 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +jwt_signing_secret = "MaxVerstappenWC2021" [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..52740721b 100644 --- a/share/default/config/index.public.e2e.container.sqlite3.toml +++ b/share/default/config/index.public.e2e.container.sqlite3.toml @@ -17,7 +17,7 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +jwt_signing_secret = "MaxVerstappenWC2021" [database] connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" diff --git a/src/app.rs b/src/app.rs index af20428d5..7e2b58ec6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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(), diff --git a/src/config/mod.rs b/src/config/mod.rs index e55879b93..8599198b3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -24,7 +24,7 @@ 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 SecretKey = v2::auth::JwtSigningSecret; pub type PasswordConstraints = v2::auth::PasswordConstraints; pub type Database = v2::database::Database; @@ -350,18 +350,25 @@ impl Configuration { /// 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", + "auth.jwt_signing_secret", "logging.threshold", "metadata.schema_version", "tracker.token", ]; for mandatory_option in mandatory_options { - figment - .find_value(mandatory_option) - .map_err(|_err| Error::MissingMandatoryOption { + // Accept both the canonical key and the legacy alias. + let found = figment.find_value(mandatory_option).is_ok() + || match mandatory_option { + "auth.jwt_signing_secret" => figment.find_value("auth.user_claim_token_pepper").is_ok(), + _ => false, + }; + + 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..3486623c7 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -2,14 +2,28 @@ use std::fmt; use serde::{Deserialize, Serialize}; +/// 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. #[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, + /// The HMAC secret used to sign JWT tokens. + #[serde(default = "Auth::default_jwt_signing_secret")] + pub jwt_signing_secret: JwtSigningSecret, + + /// Session-token lifetime in seconds (default: 2 weeks). + #[serde(default = "Auth::default_session_token_lifetime_secs")] + pub session_token_lifetime_secs: u64, + + /// 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 + /// The password constraints. #[serde(default = "Auth::default_password_constraints")] pub password_constraints: PasswordConstraints, } @@ -17,19 +31,29 @@ pub struct Auth { impl Default for Auth { fn default() -> Self { Self { + jwt_signing_secret: Self::default_jwt_signing_secret(), + 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); + pub fn override_jwt_signing_secret(&mut self, secret: &str) { + self.jwt_signing_secret = JwtSigningSecret::new(secret); + } + + fn default_jwt_signing_secret() -> JwtSigningSecret { + JwtSigningSecret::new("MaxVerstappenWC2021") + } + + 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 { @@ -37,13 +61,18 @@ impl Auth { } } +/// The HMAC signing secret for JWT tokens. +/// +/// Renamed from `ClaimTokenPepper` (see ADR-T-007) — the old name +/// incorrectly suggested a password-hashing "pepper" rather than a +/// signing key. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ClaimTokenPepper(String); +pub struct JwtSigningSecret(String); -impl ClaimTokenPepper { +impl JwtSigningSecret { /// # Panics /// - /// Will panic if the key if empty. + /// Will panic if the key is empty. #[must_use] pub fn new(key: &str) -> Self { assert!(!key.is_empty(), "secret key cannot be empty"); @@ -57,7 +86,7 @@ impl ClaimTokenPepper { } } -impl fmt::Display for ClaimTokenPepper { +impl fmt::Display for JwtSigningSecret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } diff --git a/src/config/v2/mod.rs b/src/config/v2/mod.rs index 8c7a21331..4045391c6 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, JwtSigningSecret}; use self::database::Database; use self::image_cache::ImageCache; use self::mail::Mail; @@ -111,7 +111,7 @@ 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("***"); + self.auth.jwt_signing_secret = JwtSigningSecret::new("***"); } /// Encodes the configuration to TOML. diff --git a/src/jwt.rs b/src/jwt.rs new file mode 100644 index 000000000..73887622a --- /dev/null +++ b/src/jwt.rs @@ -0,0 +1,144 @@ +//! 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. + +use std::sync::Arc; + +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; + +use crate::config::Configuration; +use crate::errors::AuthError; +use crate::models::user::UserCompact; +use crate::utils::clock; + +// ── Claim types ────────────────────────────────────────────────────── + +/// Claims embedded in session (login) JWTs. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserClaims { + pub user: UserCompact, + pub exp: u64, +} + +/// Claims embedded in email-verification JWTs. +#[derive(Debug, Serialize, Deserialize)] +pub struct VerifyClaims { + pub iss: String, + pub sub: i64, + pub exp: u64, +} + +// ── Service ────────────────────────────────────────────────────────── + +/// Centralised JWT signing and verification service. +/// +/// Holds a reference to [`Configuration`] so it can read the signing +/// secret and token lifetimes at runtime. +pub struct JsonWebToken { + cfg: Arc, +} + +impl JsonWebToken { + pub const fn new(cfg: Arc) -> Self { + Self { cfg } + } + + /// Sign a session JWT for the given user. + /// + /// # Errors + /// + /// Returns `AuthError::InternalServerError` if the token cannot be + /// encoded (e.g. the encoding key is invalid). + pub async fn sign(&self, user: UserCompact) -> Result { + let settings = self.cfg.settings.read().await; + let key = settings.auth.jwt_signing_secret.as_bytes(); + let exp_date = clock::now() + settings.auth.session_token_lifetime_secs; + let claims = UserClaims { user, exp: exp_date }; + let result = + encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).map_err(|_| AuthError::InternalServerError); + drop(settings); + result + } + + /// Verify a session JWT and return its claims. + /// + /// Expiration is validated by the `jsonwebtoken` library; there is + /// no redundant manual check. + /// + /// # Errors + /// + /// * `AuthError::TokenExpired` — the token's `exp` is in the past. + /// * `AuthError::TokenInvalid` — signature mismatch or malformed token. + pub async fn verify(&self, token: &str) -> Result { + let settings = self.cfg.settings.read().await; + + let result = decode::( + token, + &DecodingKey::from_secret(settings.auth.jwt_signing_secret.as_bytes()), + &Validation::new(Algorithm::HS256), + ) + .map(|token_data| token_data.claims) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired, + _ => AuthError::TokenInvalid, + }); + + drop(settings); + result + } + + /// Sign an email-verification JWT for the given user ID. + /// + /// # 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 key = settings.auth.jwt_signing_secret.as_bytes(); + + let claims = VerifyClaims { + iss: String::from("email-verification"), + sub: user_id, + exp: clock::now() + settings.auth.email_verification_token_lifetime_secs, + }; + + let result = + encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).map_err(|_| AuthError::InternalServerError); + drop(settings); + result + } + + /// Verify an email-verification JWT and return its claims. + /// + /// Rejects tokens whose `iss` claim is not `"email-verification"`. + /// + /// # Errors + /// + /// * `AuthError::TokenExpired` — the token's `exp` is in the past. + /// * `AuthError::TokenInvalid` — bad signature, wrong issuer, or malformed. + pub async fn verify_email_token(&self, token: &str) -> Result { + let settings = self.cfg.settings.read().await; + + let token_data = decode::( + token, + &DecodingKey::from_secret(settings.auth.jwt_signing_secret.as_bytes()), + &Validation::new(Algorithm::HS256), + ) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired, + _ => AuthError::TokenInvalid, + })?; + + drop(settings); + + if token_data.claims.iss != "email-verification" { + return Err(AuthError::TokenInvalid); + } + + Ok(token_data.claims) + } +} diff --git a/src/lib.rs b/src/lib.rs index bf2a2b472..f736316b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,7 +189,7 @@ //! bind_address = "0.0.0.0:3001" //! //! [auth] -//! user_claim_token_pepper = "MaxVerstappenWC2021" +//! jwt_signing_secret = "MaxVerstappenWC2021" //! //! [auth.password_constraints] //! min_password_length = 6 @@ -288,6 +288,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..0ddb396eb 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -2,14 +2,16 @@ 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; @@ -91,7 +93,7 @@ impl Service { })?; // 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()).await?; Ok((token, user_compact)) } @@ -121,7 +123,7 @@ impl Service { // 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, + x if x < ONE_WEEK_IN_SECONDS => self.json_web_token.sign(user_compact.clone()).await?, _ => token.to_string(), }; @@ -129,59 +131,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>, } diff --git a/src/services/user.rs b/src/services/user.rs index f94a071fb..a1ced8ec1 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; use async_trait::async_trait; -use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; #[cfg(test)] use mockall::automock; use pbkdf2::password_hash::rand_core::OsRng; @@ -17,7 +16,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; @@ -56,6 +55,7 @@ pub struct ListingSpecification { pub struct RegistrationService { configuration: Arc, + json_web_token: Arc, mailer: Arc, user_repository: Arc>, user_profile_repository: Arc, @@ -65,12 +65,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 +186,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).await else { + return Ok(false); }; - drop(settings); - let user_id = token_data.sub; if self.user_profile_repository.verify_email(&user_id).await.is_err() { diff --git a/src/tests/config/mod.rs b/src/tests/config/mod.rs index 401d88ea3..a8a526d7c 100644 --- a/src/tests/config/mod.rs +++ b/src/tests/config/mod.rs @@ -97,7 +97,7 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - user_claim_token_pepper = "MaxVerstappenWC2021" + jwt_signing_secret = "MaxVerstappenWC2021" "#, )?; @@ -129,7 +129,7 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - user_claim_token_pepper = "MaxVerstappenWC2021" + jwt_signing_secret = "MaxVerstappenWC2021" "# .to_string(); @@ -170,13 +170,13 @@ 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_authentication_jwt_signing_secret_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", + "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET", "OVERRIDDEN AUTH SECRET KEY", ); @@ -187,10 +187,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.jwt_signing_secret, SecretKey::new("OVERRIDDEN AUTH SECRET KEY")); Ok(()) }); diff --git a/src/tests/config/v2/auth.rs b/src/tests/config/v2/auth.rs index 70ba1dec5..a1bdc6f1f 100644 --- a/src/tests/config/v2/auth.rs +++ b/src/tests/config/v2/auth.rs @@ -1,7 +1,7 @@ -use crate::config::v2::auth::ClaimTokenPepper; +use crate::config::v2::auth::JwtSigningSecret; #[test] #[should_panic(expected = "secret key cannot be empty")] fn secret_key_can_not_be_empty() { - drop(ClaimTokenPepper::new("")); + drop(JwtSigningSecret::new("")); } diff --git a/src/web/api/client/v1/contexts/settings/mod.rs b/src/web/api/client/v1/contexts/settings/mod.rs index 01a056b7f..ab9399576 100644 --- a/src/web/api/client/v1/contexts/settings/mod.rs +++ b/src/web/api/client/v1/contexts/settings/mod.rs @@ -49,7 +49,7 @@ pub struct Network { #[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] pub struct Auth { - pub user_claim_token_pepper: String, + pub jwt_signing_secret: String, pub password_constraints: PasswordConstraints, } @@ -152,7 +152,7 @@ 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(), + jwt_signing_secret: auth.jwt_signing_secret.to_string(), 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..a60238bf7 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -49,8 +49,9 @@ //! } //! ``` //! -//! **NOTICE**: The token is valid for 2 weeks (`1_209_600` seconds). After that, -//! you will have to renew the token. +//! **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 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 @@ -84,8 +85,8 @@ use hyper::http::HeaderValue; use crate::common::AppData; use crate::errors::AuthError; -use crate::models::user::{UserClaims, UserCompact, UserId}; -use crate::services::authentication::JsonWebToken; +use crate::jwt::{JsonWebToken, UserClaims}; +use crate::models::user::{UserCompact, UserId}; use crate::web::api::server::v1::extractors::bearer_token::BearerToken; pub struct Authentication { @@ -99,7 +100,11 @@ impl Authentication { } /// Create Json Web Token - pub async fn sign_jwt(&self, user: UserCompact) -> String { + /// + /// # Errors + /// + /// Returns `AuthError::InternalServerError` if the token cannot be encoded. + pub async fn sign_jwt(&self, user: UserCompact) -> Result { self.json_web_token.sign(user).await } @@ -143,17 +148,20 @@ impl Authentication { /// Parses the token from the `Authorization` header. /// -/// # Panics +/// # Errors /// -/// 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() +/// Returns `AuthError::TokenInvalid` if the header value is not valid +/// ASCII or does not contain a `Bearer ` pair. +pub fn parse_token(authorization: &HeaderValue) -> Result { + let header_str = authorization.to_str().map_err(|_| AuthError::TokenInvalid)?; + + let token = header_str.strip_prefix("Bearer").ok_or(AuthError::TokenInvalid)?.trim(); + + if token.is_empty() { + return Err(AuthError::TokenInvalid); + } + + Ok(token.to_string()) } /// If the user is logged in, returns the user's ID. Otherwise, returns `None`. diff --git a/src/web/api/server/v1/contexts/settings/mod.rs b/src/web/api/server/v1/contexts/settings/mod.rs index a920d9f8b..4e95279e6 100644 --- a/src/web/api/server/v1/contexts/settings/mod.rs +++ b/src/web/api/server/v1/contexts/settings/mod.rs @@ -54,7 +54,9 @@ //! "tsl": null //! }, //! "auth": { -//! "user_claim_token_pepper": "***", +//! "jwt_signing_secret": "***", +//! "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/user/mod.rs b/src/web/api/server/v1/contexts/user/mod.rs index 1c1461211..eb36463b0 100644 --- a/src/web/api/server/v1/contexts/user/mod.rs +++ b/src/web/api/server/v1/contexts/user/mod.rs @@ -45,7 +45,7 @@ //! //! ```toml //! [auth] -//! user_claim_token_pepper = "MaxVerstappenWC2021" +//! jwt_signing_secret = "MaxVerstappenWC2021" //! ``` //! //! Refer to the [`RegistrationForm`](crate::web::api::server::v1::contexts::user::forms::RegistrationForm) diff --git a/src/web/api/server/v1/extractors/bearer_token.rs b/src/web/api/server/v1/extractors/bearer_token.rs index fa0b0c205..f60511bfe 100644 --- a/src/web/api/server/v1/extractors/bearer_token.rs +++ b/src/web/api/server/v1/extractors/bearer_token.rs @@ -28,7 +28,7 @@ where #[allow(clippy::option_if_let_else)] match header { - Some(header_value) => Ok(Self(Some(BearerToken(parse_token(header_value))))), + Some(header_value) => Ok(Self(parse_token(header_value).ok().map(BearerToken))), None => Ok(Self(None)), } } diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index d3c563d27..cb78b28b5 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -55,7 +55,7 @@ pub struct Network { #[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] pub struct Auth { - pub user_claim_token_pepper: String, + pub jwt_signing_secret: String, pub password_constraints: PasswordConstraints, } @@ -179,7 +179,7 @@ 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(), + jwt_signing_secret: auth.jwt_signing_secret.to_string(), password_constraints: auth.password_constraints.into(), } } diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 00bd97917..b1e2d5352 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -114,7 +114,7 @@ impl TestEnv { "***".clone_into(&mut settings.mail.smtp.credentials.password); - "***".clone_into(&mut settings.auth.user_claim_token_pepper); + "***".clone_into(&mut settings.auth.jwt_signing_secret); Some(settings) } diff --git a/tests/fixtures/default_configuration.toml b/tests/fixtures/default_configuration.toml index 30302a8c4..0678bb760 100644 --- a/tests/fixtures/default_configuration.toml +++ b/tests/fixtures/default_configuration.toml @@ -44,7 +44,7 @@ url = "udp://localhost:6969" bind_address = "0.0.0.0:3001" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +jwt_signing_secret = "MaxVerstappenWC2021" [auth.password_constraints] max_password_length = 64 From 8411e7d641d9dcfe39e0756bf963773e9438579f Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Tue, 14 Apr 2026 22:41:41 +0200 Subject: [PATCH 02/10] =?UTF-8?q?refactor(jwt):=20implement=20ADR-T-007=20?= =?UTF-8?q?Phase=202=20=E2=80=94=20per-purpose=20signing=20keys=20and=20RF?= =?UTF-8?q?C=207519=20claims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the single shared JWT signing secret into two separate keys: `session_signing_key` and `email_verification_signing_key`, so a compromise of one key does not affect the other token type. Session tokens (`SessionClaims`, née `UserClaims`) now carry proper RFC 7519 registered claims: `sub` (user ID), `iss` ("torrust-index"), `aud` ("session"), `iat`, and `exp`. The `role` and `username` fields are advisory only — the authoritative role is always re-checked from the database on each authenticated request. Email-verification tokens (`VerifyClaims`) gain an `aud` ("email-verification") claim and `iat`, and are now validated with audience + issuer checks by the `jsonwebtoken` library instead of a manual `iss` string comparison. Other changes: - Enforce a 32-byte minimum length on `JwtSigningSecret`. - Accept `jwt_signing_secret` and `user_claim_token_pepper` as legacy serde aliases for `session_signing_key` for backward compatibility. - Provide `UserClaims` as a type alias so existing imports keep compiling. - Update all config files, doc examples, and tests. Ref: ADR-T-007 --- .env.local | 3 +- README.md | 9 +- adr/007-jwt-system-refactor.md | 19 +-- compose.yaml | 3 +- .../e2e/sqlite/mode/private/e2e-env-up.sh | 2 +- .../e2e/sqlite/mode/public/e2e-env-up.sh | 2 +- docs/containers.md | 5 +- .../default/config/index.container.mysql.toml | 3 +- .../config/index.container.sqlite3.toml | 3 +- .../config/index.development.sqlite3.toml | 3 +- .../index.private.e2e.container.sqlite3.toml | 3 +- .../index.public.e2e.container.mysql.toml | 3 +- .../index.public.e2e.container.sqlite3.toml | 3 +- ...tracker.private.e2e.container.sqlite3.toml | 3 +- .../tracker.public.e2e.container.sqlite3.toml | 3 +- src/config/mod.rs | 9 +- src/config/v2/auth.rs | 51 +++++-- src/config/v2/mod.rs | 3 +- src/jwt.rs | 124 ++++++++++++++---- src/lib.rs | 3 +- src/services/authentication.rs | 2 +- src/tests/config/mod.rs | 17 ++- src/tests/config/v2/auth.rs | 12 ++ .../api/client/v1/contexts/settings/mod.rs | 6 +- src/web/api/server/v1/auth.rs | 37 ++++-- .../api/server/v1/contexts/category/mod.rs | 4 +- src/web/api/server/v1/contexts/proxy/mod.rs | 2 +- .../api/server/v1/contexts/settings/mod.rs | 5 +- src/web/api/server/v1/contexts/tag/mod.rs | 4 +- src/web/api/server/v1/contexts/torrent/mod.rs | 12 +- src/web/api/server/v1/contexts/user/mod.rs | 15 ++- tests/common/contexts/settings/mod.rs | 6 +- tests/e2e/environment.rs | 3 +- tests/fixtures/default_configuration.toml | 3 +- 34 files changed, 275 insertions(+), 110 deletions(-) diff --git a/.env.local b/.env.local index c21465f0d..afe9856ca 100644 --- a/.env.local +++ b/.env.local @@ -1,6 +1,7 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc TORRUST_INDEX_CONFIG_TOML= -TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET=MaxVerstappenWC2021 +TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY=MaxVerstappenWC2021-session-key! +TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY=MaxVerstappenWC2021-emailverify! USER_ID=1000 TORRUST_TRACKER_CONFIG_TOML= TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=Sqlite3 diff --git a/README.md b/README.md index 2245a2377..94ba54461 100644 --- a/README.md +++ b/README.md @@ -95,14 +95,15 @@ 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_jwt_signing_secret` by using environmental variables:_ +- The `tracker_api_token` and the JWT signing keys by using environmental variables:_ ```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__JWT_SIGNING_SECRET="MaxVerstappenWC2021" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY="your-session-signing-secret-here!" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY="your-email-verify-secret-here!!" \ cargo run ``` @@ -127,7 +128,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`, rename `ClaimTokenPepper` → `JwtSigningSecret`, make token lifetimes configurable, and fix panics in token parsing. +- [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, split into per-purpose signing keys, and enforce minimum secret length. ## Contributing diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index 342713f3b..74f9e3b91 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -1,6 +1,6 @@ # ADR-T-007: Refactor the JWT System -**Status:** Phase 1 implemented +**Status:** Phase 2 implemented **Date:** 2026-04-14 ## Context @@ -362,9 +362,9 @@ phased rollout that subsumes Options A and B. `session_token_lifetime_secs`, `email_verification_token_lifetime_secs`. -#### Phase 2 — Claim Redesign + Per-Purpose Keys (Option B scope) +#### Phase 2 — Claim Redesign + Per-Purpose Keys (Option B scope) ✅ Implemented -- Redesign `UserClaims` → `SessionClaims`: +- ✅ Redesign `UserClaims` → `SessionClaims`: ```rust struct SessionClaims { sub: UserId, // subject = user ID @@ -376,14 +376,15 @@ phased rollout that subsumes Options A and B. username: String, // convenience, non-authoritative } ``` -- Redesign `VerifyClaims` with `aud: "email-verification"`. -- Split config into two independent keys: +- ✅ Redesign `VerifyClaims` with `aud: "email-verification"`. +- ✅ Split config into two independent keys: `auth.session_signing_key` and `auth.email_verification_signing_key`. -- Re-validate the user's role from the database on every - authenticated request (with a short-lived cache) so the token - role is advisory only. -- Enforce a minimum secret length at config validation time. +- ✅ Re-validate the user's role from the database on every + authenticated request (the authorization service already does + this via `get_role`) so the token role is advisory only. +- ✅ Enforce a minimum secret length (32 bytes) at config + validation time. - **Breaking change:** existing HS256 tokens are invalidated; users must re-login. diff --git a/compose.yaml b/compose.yaml index df9f8a127..87182a716 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,7 +13,8 @@ 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__JWT_SIGNING_SECRET=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET:-MaxVerstappenWC2021} + - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY:-MaxVerstappenWC2021-session-key!} + - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY:-MaxVerstappenWC2021-emailverify!} 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 661889af9..f377f8662 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,7 @@ 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__JWT_SIGNING_SECRET="MaxVerstappenWC2021" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY="MaxVerstappenWC2021-session-key!" \ 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 ebe444606..eb51c2c90 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,7 @@ 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__JWT_SIGNING_SECRET="MaxVerstappenWC2021" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY="MaxVerstappenWC2021-session-key!" \ 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/docs/containers.md b/docs/containers.md index 6cbf4864b..39d1db75d 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -149,7 +149,8 @@ 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__JWT_SIGNING_SECRET` - Override of the auth JWT signing secret. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY` - Override of the auth session signing key. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY` - Override of the auth email-verification signing key. If set, this value overrides any value set in the config. - `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`). @@ -202,7 +203,7 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## Run Torrust Index Container Image docker run -it \ --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ - --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__JWT_SIGNING_SECRET="MaxVerstappenWC2021" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY="MaxVerstappenWC2021-session-key!" \ --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/share/default/config/index.container.mysql.toml b/share/default/config/index.container.mysql.toml index 34384c579..7fd3168fb 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] -jwt_signing_secret = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [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 60c8c3f7a..b665c1af8 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] -jwt_signing_secret = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [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 727ca8937..200c2ee97 100644 --- a/share/default/config/index.development.sqlite3.toml +++ b/share/default/config/index.development.sqlite3.toml @@ -17,7 +17,8 @@ threshold = "info" token = "MyAccessToken" [auth] -jwt_signing_secret = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" # 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 2b23fbb2c..4ac740f78 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] -jwt_signing_secret = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [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 e81761b28..1f91bcf4f 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] -jwt_signing_secret = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [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 52740721b..0eee07b9f 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] -jwt_signing_secret = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [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..b4c8495c9 100644 --- a/share/default/config/tracker.private.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.private.e2e.container.sqlite3.toml @@ -8,7 +8,8 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [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..751572ce0 100644 --- a/share/default/config/tracker.public.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.public.e2e.container.sqlite3.toml @@ -8,7 +8,8 @@ threshold = "info" token = "MyAccessToken" [auth] -user_claim_token_pepper = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [core] listed = false diff --git a/src/config/mod.rs b/src/config/mod.rs index 8599198b3..4e19c8399 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -350,17 +350,20 @@ impl Configuration { /// obtained by default value (code), meaning the user hasn't overridden it. fn check_mandatory_options(figment: &Figment) -> Result<(), Error> { let mandatory_options = [ - "auth.jwt_signing_secret", + "auth.session_signing_key", "logging.threshold", "metadata.schema_version", "tracker.token", ]; for mandatory_option in mandatory_options { - // Accept both the canonical key and the legacy alias. + // Accept both the canonical key and the legacy aliases. let found = figment.find_value(mandatory_option).is_ok() || match mandatory_option { - "auth.jwt_signing_secret" => figment.find_value("auth.user_claim_token_pepper").is_ok(), + "auth.session_signing_key" => { + figment.find_value("auth.jwt_signing_secret").is_ok() + || figment.find_value("auth.user_claim_token_pepper").is_ok() + } _ => false, }; diff --git a/src/config/v2/auth.rs b/src/config/v2/auth.rs index 3486623c7..b397eaee8 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -8,12 +8,29 @@ 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; +/// Minimum allowed length for signing secrets (ADR-T-007 Phase 2). +const MIN_SECRET_LENGTH: usize = 32; + /// Authentication options. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Auth { - /// The HMAC secret used to sign JWT tokens. - #[serde(default = "Auth::default_jwt_signing_secret")] - pub jwt_signing_secret: JwtSigningSecret, + /// The HMAC secret used to sign session JWT tokens. + /// + /// Phase 2 (ADR-T-007): renamed from `jwt_signing_secret` to + /// `session_signing_key` to reflect per-purpose key separation. + #[serde( + default = "Auth::default_session_signing_key", + alias = "jwt_signing_secret", + alias = "user_claim_token_pepper" + )] + pub session_signing_key: JwtSigningSecret, + + /// The HMAC secret used to sign email-verification JWT tokens. + /// + /// If absent, falls back to `session_signing_key` for backward + /// compatibility, but deployers should provide a separate value. + #[serde(default = "Auth::default_email_verification_signing_key")] + pub email_verification_signing_key: JwtSigningSecret, /// Session-token lifetime in seconds (default: 2 weeks). #[serde(default = "Auth::default_session_token_lifetime_secs")] @@ -31,7 +48,8 @@ pub struct Auth { impl Default for Auth { fn default() -> Self { Self { - jwt_signing_secret: Self::default_jwt_signing_secret(), + session_signing_key: Self::default_session_signing_key(), + email_verification_signing_key: Self::default_email_verification_signing_key(), 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(), @@ -40,12 +58,20 @@ impl Default for Auth { } impl Auth { - pub fn override_jwt_signing_secret(&mut self, secret: &str) { - self.jwt_signing_secret = JwtSigningSecret::new(secret); + pub fn override_session_signing_key(&mut self, secret: &str) { + self.session_signing_key = JwtSigningSecret::new(secret); + } + + pub fn override_email_verification_signing_key(&mut self, secret: &str) { + self.email_verification_signing_key = JwtSigningSecret::new(secret); + } + + fn default_session_signing_key() -> JwtSigningSecret { + JwtSigningSecret::new("MaxVerstappenWC2021-session-key!") } - fn default_jwt_signing_secret() -> JwtSigningSecret { - JwtSigningSecret::new("MaxVerstappenWC2021") + fn default_email_verification_signing_key() -> JwtSigningSecret { + JwtSigningSecret::new("MaxVerstappenWC2021-emailverify!") } const fn default_session_token_lifetime_secs() -> u64 { @@ -70,12 +96,19 @@ impl Auth { pub struct JwtSigningSecret(String); impl JwtSigningSecret { + /// Creates a new signing secret. + /// /// # Panics /// - /// Will panic if the key is empty. + /// Will panic if the key is empty or shorter than 32 bytes. #[must_use] pub fn new(key: &str) -> Self { assert!(!key.is_empty(), "secret key cannot be empty"); + assert!( + key.len() >= MIN_SECRET_LENGTH, + "secret key must be at least {MIN_SECRET_LENGTH} bytes, got {}", + key.len() + ); Self(key.to_owned()) } diff --git a/src/config/v2/mod.rs b/src/config/v2/mod.rs index 4045391c6..3260e2071 100644 --- a/src/config/v2/mod.rs +++ b/src/config/v2/mod.rs @@ -111,7 +111,8 @@ impl Settings { let _ = self.database.connect_url.set_password(Some("***")); } "***".clone_into(&mut self.mail.smtp.credentials.password); - self.auth.jwt_signing_secret = JwtSigningSecret::new("***"); + self.auth.session_signing_key = JwtSigningSecret::new("***-redacted-session-signing-key!"); + self.auth.email_verification_signing_key = JwtSigningSecret::new("***-redacted-emailverify-secret!"); } /// Encodes the configuration to TOML. diff --git a/src/jwt.rs b/src/jwt.rs index 73887622a..2ed42d910 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -4,6 +4,16 @@ //! signing, verification, and algorithm configuration. //! //! See ADR-T-007 for the rationale behind centralising JWT handling. +//! +//! ## Phase 2 changes (ADR-T-007) +//! +//! - `UserClaims` → [`SessionClaims`] with RFC 7519 registered claims. +//! - `VerifyClaims` redesigned with `aud: "email-verification"`. +//! - Per-purpose signing keys (`session_signing_key` and +//! `email_verification_signing_key`). +//! - The `role` and `username` in [`SessionClaims`] are **advisory only**; +//! the authoritative role is re-validated from the database on every +//! authenticated request. use std::sync::Arc; @@ -12,23 +22,60 @@ use serde::{Deserialize, Serialize}; use crate::config::Configuration; use crate::errors::AuthError; -use crate::models::user::UserCompact; +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 UserClaims { - pub user: UserCompact, +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, } +/// 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. +/// +/// Phase 2: now 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, } @@ -37,7 +84,7 @@ pub struct VerifyClaims { /// Centralised JWT signing and verification service. /// /// Holds a reference to [`Configuration`] so it can read the signing -/// secret and token lifetimes at runtime. +/// secrets and token lifetimes at runtime. pub struct JsonWebToken { cfg: Arc, } @@ -55,9 +102,22 @@ impl JsonWebToken { /// encoded (e.g. the encoding key is invalid). pub async fn sign(&self, user: UserCompact) -> Result { let settings = self.cfg.settings.read().await; - let key = settings.auth.jwt_signing_secret.as_bytes(); - let exp_date = clock::now() + settings.auth.session_token_lifetime_secs; - let claims = UserClaims { user, exp: exp_date }; + let key = settings.auth.session_signing_key.as_bytes(); + let now = clock::now(); + let exp_date = now + settings.auth.session_token_lifetime_secs; + 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, + }; let result = encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).map_err(|_| AuthError::InternalServerError); drop(settings); @@ -67,19 +127,24 @@ impl JsonWebToken { /// Verify a session JWT and return its claims. /// /// Expiration is validated by the `jsonwebtoken` library; there is - /// no redundant manual check. + /// 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 async fn verify(&self, token: &str) -> Result { + pub async fn verify(&self, token: &str) -> Result { let settings = self.cfg.settings.read().await; - let result = decode::( + let mut validation = Validation::new(Algorithm::HS256); + validation.set_audience(&["session"]); + validation.set_issuer(&[ISSUER]); + + let result = decode::( token, - &DecodingKey::from_secret(settings.auth.jwt_signing_secret.as_bytes()), - &Validation::new(Algorithm::HS256), + &DecodingKey::from_secret(settings.auth.session_signing_key.as_bytes()), + &validation, ) .map(|token_data| token_data.claims) .map_err(|e| match e.kind() { @@ -93,17 +158,22 @@ impl JsonWebToken { /// Sign an email-verification JWT for the given user ID. /// + /// Uses the dedicated `email_verification_signing_key`. + /// /// # 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 key = settings.auth.jwt_signing_secret.as_bytes(); + let key = settings.auth.email_verification_signing_key.as_bytes(); + let now = clock::now(); let claims = VerifyClaims { - iss: String::from("email-verification"), + iss: ISSUER.to_owned(), + aud: "email-verification".to_owned(), sub: user_id, - exp: clock::now() + settings.auth.email_verification_token_lifetime_secs, + iat: now, + exp: now + settings.auth.email_verification_token_lifetime_secs, }; let result = @@ -114,31 +184,31 @@ impl JsonWebToken { /// Verify an email-verification JWT and return its claims. /// - /// Rejects tokens whose `iss` claim is not `"email-verification"`. + /// Validates `iss` and `aud` claims. /// /// # Errors /// /// * `AuthError::TokenExpired` — the token's `exp` is in the past. - /// * `AuthError::TokenInvalid` — bad signature, wrong issuer, or malformed. + /// * `AuthError::TokenInvalid` — bad signature, wrong issuer/audience, or malformed. pub async fn verify_email_token(&self, token: &str) -> Result { let settings = self.cfg.settings.read().await; - let token_data = decode::( + let mut validation = Validation::new(Algorithm::HS256); + validation.set_audience(&["email-verification"]); + validation.set_issuer(&[ISSUER]); + + let result = decode::( token, - &DecodingKey::from_secret(settings.auth.jwt_signing_secret.as_bytes()), - &Validation::new(Algorithm::HS256), + &DecodingKey::from_secret(settings.auth.email_verification_signing_key.as_bytes()), + &validation, ) + .map(|token_data| token_data.claims) .map_err(|e| match e.kind() { jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired, _ => AuthError::TokenInvalid, - })?; + }); drop(settings); - - if token_data.claims.iss != "email-verification" { - return Err(AuthError::TokenInvalid); - } - - Ok(token_data.claims) + result } } diff --git a/src/lib.rs b/src/lib.rs index f736316b1..769463b40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,7 +189,8 @@ //! bind_address = "0.0.0.0:3001" //! //! [auth] -//! jwt_signing_secret = "MaxVerstappenWC2021" +//! session_signing_key = "MaxVerstappenWC2021-session-key!" +//! email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" //! //! [auth.password_constraints] //! min_password_length = 6 diff --git a/src/services/authentication.rs b/src/services/authentication.rs index 0ddb396eb..349192982 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -114,7 +114,7 @@ impl Service { let user_compact = self .user_repository - .get_compact(&claims.user.user_id) + .get_compact(&claims.sub) .await .map_err(|err| match err { Error::UserNotFound => AuthError::UserNotFound, diff --git a/src/tests/config/mod.rs b/src/tests/config/mod.rs index a8a526d7c..15cad45db 100644 --- a/src/tests/config/mod.rs +++ b/src/tests/config/mod.rs @@ -97,7 +97,8 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - jwt_signing_secret = "MaxVerstappenWC2021" + session_signing_key = "MaxVerstappenWC2021-session-key!" + email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" "#, )?; @@ -129,7 +130,8 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - jwt_signing_secret = "MaxVerstappenWC2021" + session_signing_key = "MaxVerstappenWC2021-session-key!" + email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" "# .to_string(); @@ -170,14 +172,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_jwt_signing_secret_provided_in_the_toml_file() { +async fn configuration_should_allow_to_override_the_session_signing_key_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__JWT_SIGNING_SECRET", - "OVERRIDDEN AUTH SECRET KEY", + "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY", + "OVERRIDDEN-SESSION-SIGNING-SECRET!", ); let info = Info { @@ -187,7 +189,10 @@ async fn configuration_should_allow_to_override_the_authentication_jwt_signing_s let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); - assert_eq!(settings.auth.jwt_signing_secret, SecretKey::new("OVERRIDDEN AUTH SECRET KEY")); + assert_eq!( + settings.auth.session_signing_key, + SecretKey::new("OVERRIDDEN-SESSION-SIGNING-SECRET!") + ); Ok(()) }); diff --git a/src/tests/config/v2/auth.rs b/src/tests/config/v2/auth.rs index a1bdc6f1f..321bba203 100644 --- a/src/tests/config/v2/auth.rs +++ b/src/tests/config/v2/auth.rs @@ -5,3 +5,15 @@ use crate::config::v2::auth::JwtSigningSecret; fn secret_key_can_not_be_empty() { drop(JwtSigningSecret::new("")); } + +#[test] +#[should_panic(expected = "secret key must be at least 32 bytes")] +fn secret_key_must_meet_minimum_length() { + drop(JwtSigningSecret::new("too-short")); +} + +#[test] +fn secret_key_accepts_valid_length() { + let key = JwtSigningSecret::new("a]32-byte-minimum-length-secret!"); + assert_eq!(key.as_bytes().len(), 32); +} diff --git a/src/web/api/client/v1/contexts/settings/mod.rs b/src/web/api/client/v1/contexts/settings/mod.rs index ab9399576..375ecd6ca 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 jwt_signing_secret: String, + pub session_signing_key: String, + pub email_verification_signing_key: String, pub password_constraints: PasswordConstraints, } @@ -152,7 +153,8 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { Self { - jwt_signing_secret: auth.jwt_signing_secret.to_string(), + session_signing_key: auth.session_signing_key.to_string(), + email_verification_signing_key: auth.email_verification_signing_key.to_string(), 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 a60238bf7..9201738ae 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -42,20 +42,39 @@ //! ```json //! { //! "data":{ -//! "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI", +//! "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak", //! "username":"indexadmin", //! "admin":true //! } //! } //! ``` //! +//! The JWT payload contains RFC 7519 registered claims: +//! +//! ```json +//! { +//! "sub": 1, +//! "iss": "torrust-index", +//! "aud": "session", +//! "iat": 1686215788, +//! "exp": 1687425388, +//! "role": "admin", +//! "username": "indexadmin" +//! } +//! ``` +//! +//! 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). +//! //! **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 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. +//! **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 //! @@ -66,7 +85,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --request POST \ //! --data '{"name":"new category","icon":null}' \ //! http://127.0.0.1:3001/v1/category @@ -85,7 +104,7 @@ use hyper::http::HeaderValue; use crate::common::AppData; use crate::errors::AuthError; -use crate::jwt::{JsonWebToken, UserClaims}; +use crate::jwt::{JsonWebToken, SessionClaims}; use crate::models::user::{UserCompact, UserId}; use crate::web::api::server::v1::extractors::bearer_token::BearerToken; @@ -113,7 +132,7 @@ impl Authentication { /// # Errors /// /// This function will return an error if the JWT is not good or expired. - pub async fn verify_jwt(&self, token: &str) -> Result { + pub async fn verify_jwt(&self, token: &str) -> Result { self.json_web_token.verify(token).await } @@ -124,7 +143,7 @@ impl Authentication { /// 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) + Ok(claims.sub) } /// Get Claims from bearer token @@ -135,7 +154,7 @@ impl Authentication { /// /// - 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 { + 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), diff --git a/src/web/api/server/v1/contexts/category/mod.rs b/src/web/api/server/v1/contexts/category/mod.rs index c6ed8a712..6ae5902c7 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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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..8b99a8ee3 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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 4e95279e6..91e8e2cff 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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --request GET \ //! "http://127.0.0.1:3001/v1/settings" //! ``` @@ -54,7 +54,8 @@ //! "tsl": null //! }, //! "auth": { -//! "jwt_signing_secret": "***", +//! "session_signing_key": "***", +//! "email_verification_signing_key": "***", //! "session_token_lifetime_secs": 1209600, //! "email_verification_token_lifetime_secs": 315569260, //! "password_constraints": { diff --git a/src/web/api/server/v1/contexts/tag/mod.rs b/src/web/api/server/v1/contexts/tag/mod.rs index eb4dd68db..c26d4995a 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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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..11a6edc65 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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --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 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --request DELETE \ //! "http://127.0.0.1:3001/v1/torrent/5452869BE36F9F3350CCEE6B4544E7E76CAAADAB" //! ``` diff --git a/src/web/api/server/v1/contexts/user/mod.rs b/src/web/api/server/v1/contexts/user/mod.rs index eb36463b0..a551afc0f 100644 --- a/src/web/api/server/v1/contexts/user/mod.rs +++ b/src/web/api/server/v1/contexts/user/mod.rs @@ -45,7 +45,8 @@ //! //! ```toml //! [auth] -//! jwt_signing_secret = "MaxVerstappenWC2021" +//! session_signing_key = "your-session-signing-secret-here!" +//! email_verification_signing_key = "your-email-verify-secret-here!!" //! ``` //! //! Refer to the [`RegistrationForm`](crate::web::api::server::v1::contexts::user::forms::RegistrationForm) @@ -121,7 +122,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 | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak` //! //! Refer to the [`JsonWebToken`](crate::web::api::server::v1::contexts::user::forms::JsonWebToken) //! struct for more information about the token. @@ -132,7 +133,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request POST \ -//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ +//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak"}' \ //! http://127.0.0.1:3001/v1/user/token/verify //! ``` //! @@ -167,7 +168,7 @@ //! //! Name | Type | Description | Required | Example //! ---|---|---|---|--- -//! `token` | `String` | The current valid token | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI` +//! `token` | `String` | The current valid token | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak` //! //! Refer to the [`JsonWebToken`](crate::web::api::server::v1::contexts::user::forms::JsonWebToken) //! struct for more information about the token. @@ -178,7 +179,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request POST \ -//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI"}' \ +//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak"}' \ //! http://127.0.0.1:3001/v1/user/token/renew //! ``` //! @@ -189,7 +190,7 @@ //! ```json //! { //! "data": { -//! "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI", +//! "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak", //! "username": "indexadmin", //! "admin": true //! } @@ -223,7 +224,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp7InVzZXJfaWQiOjEsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImFkbWluaXN0cmF0b3IiOnRydWV9LCJleHAiOjE2ODYyMTU3ODh9.4k8ty27DiWwOk4WVcYEhIrAndhpXMRWnLZ3i_HlJnvI" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ //! --request DELETE \ //! http://127.0.0.1:3001/v1/user/ban/indexadmin //! ``` diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index cb78b28b5..570015093 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 jwt_signing_secret: String, + pub session_signing_key: String, + pub email_verification_signing_key: String, pub password_constraints: PasswordConstraints, } @@ -179,7 +180,8 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { Self { - jwt_signing_secret: auth.jwt_signing_secret.to_string(), + session_signing_key: auth.session_signing_key.to_string(), + email_verification_signing_key: auth.email_verification_signing_key.to_string(), password_constraints: auth.password_constraints.into(), } } diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index b1e2d5352..66a22ab0d 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -114,7 +114,8 @@ impl TestEnv { "***".clone_into(&mut settings.mail.smtp.credentials.password); - "***".clone_into(&mut settings.auth.jwt_signing_secret); + "***-redacted-session-signing-key!".clone_into(&mut settings.auth.session_signing_key); + "***-redacted-emailverify-secret!".clone_into(&mut settings.auth.email_verification_signing_key); Some(settings) } diff --git a/tests/fixtures/default_configuration.toml b/tests/fixtures/default_configuration.toml index 0678bb760..b53ffbbb8 100644 --- a/tests/fixtures/default_configuration.toml +++ b/tests/fixtures/default_configuration.toml @@ -44,7 +44,8 @@ url = "udp://localhost:6969" bind_address = "0.0.0.0:3001" [auth] -jwt_signing_secret = "MaxVerstappenWC2021" +session_signing_key = "MaxVerstappenWC2021-session-key!" +email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" [auth.password_constraints] max_password_length = 64 From 40fe5e59af9adfcf034517d68d7cd9d653f19b9a Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Tue, 14 Apr 2026 23:24:27 +0200 Subject: [PATCH 03/10] refactor(jwt)!: switch from HMAC-HS256 to RS256 asymmetric signing (ADR-T-007 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the shared HMAC secret (`session_signing_key` / `email_verification_signing_key`) with an RSA-2048 key pair for all JWT operations. Key changes: - Sign tokens with `EncodingKey` (private) and verify with `DecodingKey` (public), both resolved once at startup from PEM files or inline PEM configuration. - Include a `kid` (Key ID) header derived from the SHA-256 hash of the public key, preparing for future key rotation. - Ship a development key pair at `share/default/jwt/` with a loud startup warning when it is used. - Remove `JwtSigningSecret`, the mandatory-option check for `auth.session_signing_key`, and all three serde aliases. - Verification (`verify`, `verify_email_token`) is now synchronous since keys are pre-loaded — no config lock needed per request. - Update all config files, container scripts, tests, and docs to use `private_key_path` / `public_key_path`. BREAKING CHANGE: The `auth.session_signing_key` and `auth.email_verification_signing_key` config fields are removed. Deployers must provide an RSA key pair via `auth.private_key_path` / `auth.public_key_path` (or the inline `_pem` variants). Existing HS256 tokens will be rejected after upgrade. --- .env.local | 4 +- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 13 +- adr/007-jwt-system-refactor.md | 28 +-- compose.yaml | 4 +- .../e2e/sqlite/mode/private/e2e-env-up.sh | 3 +- .../e2e/sqlite/mode/public/e2e-env-up.sh | 3 +- docs/containers.md | 9 +- .../default/config/index.container.mysql.toml | 4 +- .../config/index.container.sqlite3.toml | 4 +- .../config/index.development.sqlite3.toml | 4 +- .../index.private.e2e.container.sqlite3.toml | 4 +- .../index.public.e2e.container.mysql.toml | 4 +- .../index.public.e2e.container.sqlite3.toml | 4 +- ...tracker.private.e2e.container.sqlite3.toml | 4 +- .../tracker.public.e2e.container.sqlite3.toml | 4 +- share/default/jwt/private.pem | 28 +++ share/default/jwt/public.pem | 9 + src/app.rs | 2 +- src/config/mod.rs | 18 +- src/config/v2/auth.rs | 166 +++++++++++------- src/config/v2/mod.rs | 6 +- src/jwt.rs | 148 +++++++++------- src/lib.rs | 4 +- src/services/authentication.rs | 2 +- src/services/user.rs | 2 +- src/tests/config/mod.rs | 21 +-- src/tests/config/v2/auth.rs | 43 +++-- .../api/client/v1/contexts/settings/mod.rs | 8 +- src/web/api/server/v1/auth.rs | 32 ++-- .../api/server/v1/contexts/settings/mod.rs | 6 +- .../api/server/v1/contexts/user/handlers.rs | 2 +- src/web/api/server/v1/contexts/user/mod.rs | 4 +- .../server/v1/extractors/optional_user_id.rs | 2 +- src/web/api/server/v1/extractors/user_id.rs | 2 +- tests/common/contexts/settings/mod.rs | 8 +- tests/e2e/environment.rs | 3 +- tests/fixtures/default_configuration.toml | 4 +- 39 files changed, 370 insertions(+), 248 deletions(-) create mode 100644 share/default/jwt/private.pem create mode 100644 share/default/jwt/public.pem diff --git a/.env.local b/.env.local index afe9856ca..b2c2a023d 100644 --- a/.env.local +++ b/.env.local @@ -1,7 +1,7 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc TORRUST_INDEX_CONFIG_TOML= -TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY=MaxVerstappenWC2021-session-key! -TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY=MaxVerstappenWC2021-emailverify! +TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH=./share/default/jwt/private.pem +TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH=./share/default/jwt/public.pem USER_ID=1000 TORRUST_TRACKER_CONFIG_TOML= TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=Sqlite3 diff --git a/Cargo.lock b/Cargo.lock index e38391653..fc332f664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4281,6 +4281,7 @@ dependencies = [ "serde_json", "serde_with", "sha-1", + "sha2", "sqlx", "tempfile", "tera", diff --git a/Cargo.toml b/Cargo.toml index d6eaeaeeb..31adb8a9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,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" diff --git a/README.md b/README.md index 94ba54461..357c3dd85 100644 --- a/README.md +++ b/README.md @@ -95,15 +95,18 @@ TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") cargo run _For deployment, you __should__ override: -- The `tracker_api_token` and the JWT signing keys by using environmental variables:_ +- The `tracker_api_token` and RSA key paths by using environmental variables:_ ```sh -# Please use the secret that you generated for the torrust-tracker configuration. +# Generate an RSA key pair for JWT signing: +# openssl genrsa -out private.pem 2048 +# openssl rsa -in private.pem -pubout -out public.pem + # 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__SESSION_SIGNING_KEY="your-session-signing-secret-here!" \ - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY="your-email-verify-secret-here!!" \ + 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 ``` @@ -128,7 +131,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, split into per-purpose signing keys, and enforce minimum secret length. +- [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, and move to RS256 asymmetric signing with a public/private RSA key pair. ## Contributing diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index 74f9e3b91..d07a9c487 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -1,6 +1,6 @@ # ADR-T-007: Refactor the JWT System -**Status:** Phase 2 implemented +**Status:** Phase 3 implemented **Date:** 2026-04-14 ## Context @@ -388,21 +388,25 @@ phased rollout that subsumes Options A and B. - **Breaking change:** existing HS256 tokens are invalidated; users must re-login. -#### Phase 3 — RS256 Asymmetric Signing (Option C scope) +#### Phase 3 — RS256 Asymmetric Signing (Option C scope) ✅ Implemented -- Replace `HS256` with `RS256` (`Algorithm::RS256`). -- Config provides: +- ✅ Replace `HS256` with `RS256` (`Algorithm::RS256`). +- ✅ Config provides: - `auth.private_key_path` (PEM / PKCS#8) for signing. - `auth.public_key_path` for verification. - - Alternatively, inline PEM via environment variable. -- Generate a default development key pair on first run (with a - loud warning) so the zero-config experience is preserved for - local development. -- Use `EncodingKey::from_rsa_pem` / `DecodingKey::from_rsa_pem`. -- Only the signing service loads the private key; the + - Alternatively, inline PEM via environment variable + (`auth.private_key_pem`, `auth.public_key_pem`). +- ✅ Development key pair shipped at `share/default/jwt/` with loud + startup warning when the default dev keys are used. +- ✅ Use `EncodingKey::from_rsa_pem` / `DecodingKey::from_rsa_pem`. +- ✅ Only the signing service loads the private key; the verification path uses the public key. -- Add a `kid` (Key ID) field to the JWT header to support future - key rotation. +- ✅ A `kid` (Key ID) is included in every JWT header (SHA-256 + fingerprint of the public key) to support future key rotation. +- **Breaking change:** existing HS256 tokens and config + (`session_signing_key`, `email_verification_signing_key`) are + no longer supported. Deployers must generate an RSA key pair + and update their configuration. #### Future — Optional Revocation (Option E scope) diff --git a/compose.yaml b/compose.yaml index 87182a716..e2b4ad504 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,8 +13,8 @@ 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__SESSION_SIGNING_KEY=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY:-MaxVerstappenWC2021-session-key!} - - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY:-MaxVerstappenWC2021-emailverify!} + - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH:-/var/lib/torrust/index/jwt/private.pem} + - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH:-/var/lib/torrust/index/jwt/public.pem} 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 f377f8662..582c49256 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,8 @@ 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__SESSION_SIGNING_KEY="MaxVerstappenWC2021-session-key!" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH="/var/lib/torrust/index/jwt/private.pem" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/var/lib/torrust/index/jwt/public.pem" \ 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 eb51c2c90..5a313f3e2 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,8 @@ 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__SESSION_SIGNING_KEY="MaxVerstappenWC2021-session-key!" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH="/var/lib/torrust/index/jwt/private.pem" \ + TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/var/lib/torrust/index/jwt/public.pem" \ 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/docs/containers.md b/docs/containers.md index 39d1db75d..e14243314 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -149,8 +149,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__SESSION_SIGNING_KEY` - Override of the auth session signing key. If set, this value overrides any value set in the config. -- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__EMAIL_VERIFICATION_SIGNING_KEY` - Override of the auth email-verification signing key. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH` - Override of the RSA private key PEM file path for JWT signing. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH` - Override of the RSA public key PEM file path for JWT verification. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PEM` - Override with an inline RSA private key PEM string instead of a file path. +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PEM` - Override with an inline RSA public key PEM string instead of a file path. - `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`). @@ -203,7 +205,8 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## Run Torrust Index Container Image docker run -it \ --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ - --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__SESSION_SIGNING_KEY="MaxVerstappenWC2021-session-key!" \ + --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" \ --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/share/default/config/index.container.mysql.toml b/share/default/config/index.container.mysql.toml index 7fd3168fb..52864dcfb 100644 --- a/share/default/config/index.container.mysql.toml +++ b/share/default/config/index.container.mysql.toml @@ -15,8 +15,8 @@ threshold = "info" token = "MyAccessToken" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "/var/lib/torrust/index/jwt/private.pem" +public_key_path = "/var/lib/torrust/index/jwt/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 b665c1af8..c2fe1417f 100644 --- a/share/default/config/index.container.sqlite3.toml +++ b/share/default/config/index.container.sqlite3.toml @@ -15,8 +15,8 @@ threshold = "info" token = "MyAccessToken" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "/var/lib/torrust/index/jwt/private.pem" +public_key_path = "/var/lib/torrust/index/jwt/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 200c2ee97..97b1bbbaa 100644 --- a/share/default/config/index.development.sqlite3.toml +++ b/share/default/config/index.development.sqlite3.toml @@ -17,8 +17,8 @@ threshold = "info" token = "MyAccessToken" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "./share/default/jwt/private.pem" +public_key_path = "./share/default/jwt/public.pem" # 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 4ac740f78..d69dbf78c 100644 --- a/share/default/config/index.private.e2e.container.sqlite3.toml +++ b/share/default/config/index.private.e2e.container.sqlite3.toml @@ -19,8 +19,8 @@ token = "MyAccessToken" url = "http://tracker:7070" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "/var/lib/torrust/index/jwt/private.pem" +public_key_path = "/var/lib/torrust/index/jwt/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 1f91bcf4f..cd412fb31 100644 --- a/share/default/config/index.public.e2e.container.mysql.toml +++ b/share/default/config/index.public.e2e.container.mysql.toml @@ -17,8 +17,8 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "/var/lib/torrust/index/jwt/private.pem" +public_key_path = "/var/lib/torrust/index/jwt/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 0eee07b9f..8cc4785ec 100644 --- a/share/default/config/index.public.e2e.container.sqlite3.toml +++ b/share/default/config/index.public.e2e.container.sqlite3.toml @@ -17,8 +17,8 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "/var/lib/torrust/index/jwt/private.pem" +public_key_path = "/var/lib/torrust/index/jwt/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 b4c8495c9..3f3b83bd2 100644 --- a/share/default/config/tracker.private.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.private.e2e.container.sqlite3.toml @@ -8,8 +8,8 @@ threshold = "info" token = "MyAccessToken" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "/var/lib/torrust/index/jwt/private.pem" +public_key_path = "/var/lib/torrust/index/jwt/public.pem" [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 751572ce0..11a23e0af 100644 --- a/share/default/config/tracker.public.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.public.e2e.container.sqlite3.toml @@ -8,8 +8,8 @@ threshold = "info" token = "MyAccessToken" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "/var/lib/torrust/index/jwt/private.pem" +public_key_path = "/var/lib/torrust/index/jwt/public.pem" [core] listed = false diff --git a/share/default/jwt/private.pem b/share/default/jwt/private.pem new file mode 100644 index 000000000..f521bba45 --- /dev/null +++ b/share/default/jwt/private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxwHRc62iUwASm +6wsBtgAZx5mllHK9D1LlezLfYYxvggdS3Uj9tVQTbM4kso8cztxSp1Za3lskiU7J +xZbbLk0skxkCUfyubfuqT7w/Vqjy30HrMaXDSPmSxTATaibUu/v809TAf2+8uPPy +0aBoaPf7H/ru0dhfhgRiK65doDA3810UBM+QO2sZ05JtC/N48tbdX3Fr/LlXHv/2 +eqMqTvhHaN8CMwhcwKJMaFmp8ePVxopzruhp1qq8KJ9agKUNV9MVhcQhy7wWmz74 +rRE+Jm/b9uQQ02czBLMEMgfKu4+Z9bNsQwu6KbdPHxGuK8pF4DBlIjPcZRhpJIZp +deAVTJppAgMBAAECggEABY5W9MqU78Vav97r7qdCNIwVJORRe9BdTnf93JaffaLK +WNA65ldDJAJMJUCBkaxznY/GdrupjKhQEqV+9CXr2p9Uckqew9MDQU0RvMcYK9NH +q7LXFBYVWv2X5Zt1UEP5+eqjJUs2cmczlNzxjyHs0mgq/0kG4uF9BJaJ8jo+F5mR +1dZ4spKnavSSrq5YtMiaskCqKq74RuCGbmq7QtGNH/gAZXC//IIBL9oywOlXJyyx +nK7bhNWFyf2Q0f3e7Y+Mfvz/dJhIY3FD6H+r+XsN/qEFoArDS22PB8IxuW0RLAl2 +JRzsQCrAzteoK54eBCErOYKy6i31C/UUKvNi1QK2KQKBgQDy++PkL6OoqlOkG8XE +5sU/HktMWRHFxiecZeJC0gnGZ5mo40T0rgxFNJ8k0nE5Sc12dyztGt2g7HOJ88D+ +v5X9Wd2dzPeXkhzVul3DPRg6kDY8ZVYpUa0hTwnuY5kAXe8eUhdIE+3Aula3gB6Q +fm++5qT1S4CuIIywFmgj5tap1QKBgQC7RgQ8CiHqGYWZjnFvefUr6mXii+8ElM18 +UOclulUSsR4VgtMntTQ+3kB5/6sl08l9ydYflpM8PCL0lvaFcgBQAtPlZetzmFhA +aGodLzpjUv+rmTH3z51erjFZX0m5pguu9ivYAhYZv4bN3gkFwsrQgs9r0fRyCteu +VJvqPuOERQKBgA3H9Yvqm8ikKGxFWvko8YT77d9dqeFitLptGOEbUoybMZ7fjPin +qnB+ZIxNFzjdk7alWbn07R8EaiUn2wlXymT9JNGfX2eMVPBWSp0ZKPehWEIiqTlc +tYoPFowbwADCUx6QH1vqLXDh4Ks1rAYb9bCJGlADQUAe/nu6OZvXqtMlAoGARY1X +fUT2G4+nAsTYdGKDH/BKLr1x4+2v83/ImUZ+2hZV6f9QlOrDoKXCpIzD76ScrM8N +a2XtAO4EvXpjzGPuocirEgOsUp4+CI2++1/S+5iTxBN9b1/4PnXLdjnhk8WLiUt8 +NRlxQ9bSJhtUloMl+BLdHlo3wzMrr19VGMaKkVECgYEAjsWmpSM8c1KL7m94bvow +EkSlvH69fOhFMQnjFmhmkjjXhBTMYrAUrh5Evdgkvyq61zVFVqegQjIkixqLPJyK +WGJbdPugw9cyL6EroM/ZNWxedw2A8GvXMCBswHUZfp1yMErJwlDDCdluGvpUQUlO +kqS6GexERq/c2NQgLqd68HM= +-----END PRIVATE KEY----- diff --git a/share/default/jwt/public.pem b/share/default/jwt/public.pem new file mode 100644 index 000000000..baaa0211d --- /dev/null +++ b/share/default/jwt/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAscB0XOtolMAEpusLAbYA +GceZpZRyvQ9S5Xsy32GMb4IHUt1I/bVUE2zOJLKPHM7cUqdWWt5bJIlOycWW2y5N +LJMZAlH8rm37qk+8P1ao8t9B6zGlw0j5ksUwE2om1Lv7/NPUwH9vvLjz8tGgaGj3 ++x/67tHYX4YEYiuuXaAwN/NdFATPkDtrGdOSbQvzePLW3V9xa/y5Vx7/9nqjKk74 +R2jfAjMIXMCiTGhZqfHj1caKc67oadaqvCifWoClDVfTFYXEIcu8Fps++K0RPiZv +2/bkENNnMwSzBDIHyruPmfWzbEMLuim3Tx8RrivKReAwZSIz3GUYaSSGaXXgFUya +aQIDAQAB +-----END PUBLIC KEY----- diff --git a/src/app.rs b/src/app.rs index 7e2b58ec6..00b8830a8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,7 +73,7 @@ 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 json_web_token = Arc::new(JsonWebToken::new(configuration.clone()).await); let auth = Arc::new(Authentication::new(json_web_token.clone())); // Repositories diff --git a/src/config/mod.rs b/src/config/mod.rs index 4e19c8399..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::JwtSigningSecret; pub type PasswordConstraints = v2::auth::PasswordConstraints; pub type Database = v2::database::Database; @@ -349,23 +348,10 @@ 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.session_signing_key", - "logging.threshold", - "metadata.schema_version", - "tracker.token", - ]; + let mandatory_options = ["logging.threshold", "metadata.schema_version", "tracker.token"]; for mandatory_option in mandatory_options { - // Accept both the canonical key and the legacy aliases. - let found = figment.find_value(mandatory_option).is_ok() - || match mandatory_option { - "auth.session_signing_key" => { - figment.find_value("auth.jwt_signing_secret").is_ok() - || figment.find_value("auth.user_claim_token_pepper").is_ok() - } - _ => false, - }; + let found = figment.find_value(mandatory_option).is_ok(); if !found { return Err(Error::MissingMandatoryOption { diff --git a/src/config/v2/auth.rs b/src/config/v2/auth.rs index b397eaee8..8e31aa07a 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -1,6 +1,7 @@ -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; @@ -8,29 +9,48 @@ 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; -/// Minimum allowed length for signing secrets (ADR-T-007 Phase 2). -const MIN_SECRET_LENGTH: usize = 32; +/// Default paths for the development RSA key pair shipped with the repo. +const DEFAULT_PRIVATE_KEY_PATH: &str = "./share/default/jwt/private.pem"; +const DEFAULT_PUBLIC_KEY_PATH: &str = "./share/default/jwt/public.pem"; /// Authentication options. +/// +/// ## Phase 3 (ADR-T-007) +/// +/// JWT signing has moved from HMAC-HS256 with shared secrets to +/// RS256 (RSA + SHA-256) with a public/private key pair. +/// +/// 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, the development key pair shipped at +/// `share/default/jwt/` is used with a loud warning. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Auth { - /// The HMAC secret used to sign session JWT tokens. + /// Inline RSA private key in PEM format (overrides `private_key_path`). /// - /// Phase 2 (ADR-T-007): renamed from `jwt_signing_secret` to - /// `session_signing_key` to reflect per-purpose key separation. - #[serde( - default = "Auth::default_session_signing_key", - alias = "jwt_signing_secret", - alias = "user_claim_token_pepper" - )] - pub session_signing_key: JwtSigningSecret, - - /// The HMAC secret used to sign email-verification JWT tokens. + /// 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`). /// - /// If absent, falls back to `session_signing_key` for backward - /// compatibility, but deployers should provide a separate value. - #[serde(default = "Auth::default_email_verification_signing_key")] - pub email_verification_signing_key: JwtSigningSecret, + /// 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")] @@ -48,8 +68,10 @@ pub struct Auth { impl Default for Auth { fn default() -> Self { Self { - session_signing_key: Self::default_session_signing_key(), - email_verification_signing_key: Self::default_email_verification_signing_key(), + private_key_pem: None, + public_key_pem: None, + private_key_path: Self::default_private_key_path(), + public_key_path: Self::default_public_key_path(), 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(), @@ -58,20 +80,14 @@ impl Default for Auth { } impl Auth { - pub fn override_session_signing_key(&mut self, secret: &str) { - self.session_signing_key = JwtSigningSecret::new(secret); + #[allow(clippy::unnecessary_wraps)] // serde default must match the field type + fn default_private_key_path() -> Option { + Some(DEFAULT_PRIVATE_KEY_PATH.to_owned()) } - pub fn override_email_verification_signing_key(&mut self, secret: &str) { - self.email_verification_signing_key = JwtSigningSecret::new(secret); - } - - fn default_session_signing_key() -> JwtSigningSecret { - JwtSigningSecret::new("MaxVerstappenWC2021-session-key!") - } - - fn default_email_verification_signing_key() -> JwtSigningSecret { - JwtSigningSecret::new("MaxVerstappenWC2021-emailverify!") + #[allow(clippy::unnecessary_wraps)] // serde default must match the field type + fn default_public_key_path() -> Option { + Some(DEFAULT_PUBLIC_KEY_PATH.to_owned()) } const fn default_session_token_lifetime_secs() -> u64 { @@ -85,43 +101,75 @@ impl Auth { fn default_password_constraints() -> PasswordConstraints { PasswordConstraints::default() } -} - -/// The HMAC signing secret for JWT tokens. -/// -/// Renamed from `ClaimTokenPepper` (see ADR-T-007) — the old name -/// incorrectly suggested a password-hashing "pepper" rather than a -/// signing key. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct JwtSigningSecret(String); -impl JwtSigningSecret { - /// Creates a new signing secret. + /// Resolve the RSA private key PEM bytes. + /// + /// Resolution order: + /// 1. Inline PEM (`private_key_pem`) + /// 2. File path (`private_key_path`) + /// 3. Fallback to default dev key path (with warning) /// /// # Panics /// - /// Will panic if the key is empty or shorter than 32 bytes. + /// Panics if no valid private key PEM can be resolved. #[must_use] - pub fn new(key: &str) -> Self { - assert!(!key.is_empty(), "secret key cannot be empty"); - assert!( - key.len() >= MIN_SECRET_LENGTH, - "secret key must be at least {MIN_SECRET_LENGTH} bytes, got {}", - key.len() - ); + pub fn resolve_private_key_pem(&self) -> Vec { + if let Some(ref pem) = self.private_key_pem { + return pem.as_bytes().to_vec(); + } + + if let Some(ref path) = self.private_key_path { + if Path::new(path).exists() { + if path == DEFAULT_PRIVATE_KEY_PATH { + warn!( + "Using the DEVELOPMENT RSA private key at `{path}`. \ + This key is PUBLIC and must NOT be used in production! \ + Generate your own key pair: \ + `openssl genrsa -out private.pem 2048 && openssl rsa -in private.pem -pubout -out public.pem`" + ); + } + return std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA private key from `{path}`: {e}")); + } + } - Self(key.to_owned()) + panic!( + "No RSA private key configured. Set `auth.private_key_path` or `auth.private_key_pem` in the configuration, \ + or generate a key pair: `openssl genrsa -out private.pem 2048`" + ); } + /// Resolve the RSA public key PEM bytes. + /// + /// Resolution order: + /// 1. Inline PEM (`public_key_pem`) + /// 2. File path (`public_key_path`) + /// 3. Fallback to default dev key path (with warning) + /// + /// # Panics + /// + /// Panics if no valid public key PEM can be resolved. #[must_use] - pub fn as_bytes(&self) -> &[u8] { - self.0.as_bytes() - } -} + pub fn resolve_public_key_pem(&self) -> Vec { + if let Some(ref pem) = self.public_key_pem { + return pem.as_bytes().to_vec(); + } + + if let Some(ref path) = self.public_key_path { + if Path::new(path).exists() { + if path == DEFAULT_PUBLIC_KEY_PATH { + warn!( + "Using the DEVELOPMENT RSA public key at `{path}`. \ + This key is PUBLIC and must NOT be used in production!" + ); + } + return std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA public key from `{path}`: {e}")); + } + } -impl fmt::Display for JwtSigningSecret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) + panic!( + "No RSA public key configured. Set `auth.public_key_path` or `auth.public_key_pem` in the configuration, \ + or generate a key pair: `openssl rsa -in private.pem -pubout -out public.pem`" + ); } } diff --git a/src/config/v2/mod.rs b/src/config/v2/mod.rs index 3260e2071..20a6edbea 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, JwtSigningSecret}; +use self::auth::Auth; use self::database::Database; use self::image_cache::ImageCache; use self::mail::Mail; @@ -111,8 +111,8 @@ impl Settings { let _ = self.database.connect_url.set_password(Some("***")); } "***".clone_into(&mut self.mail.smtp.credentials.password); - self.auth.session_signing_key = JwtSigningSecret::new("***-redacted-session-signing-key!"); - self.auth.email_verification_signing_key = JwtSigningSecret::new("***-redacted-emailverify-secret!"); + self.auth.private_key_pem = Some("***-redacted-private-key-pem***".to_owned()); + self.auth.private_key_path = Some("***-redacted***".to_owned()); } /// Encodes the configuration to TOML. diff --git a/src/jwt.rs b/src/jwt.rs index 2ed42d910..82de4dcf0 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -5,20 +5,23 @@ //! //! See ADR-T-007 for the rationale behind centralising JWT handling. //! -//! ## Phase 2 changes (ADR-T-007) +//! ## Phase 3 changes (ADR-T-007) //! -//! - `UserClaims` → [`SessionClaims`] with RFC 7519 registered claims. -//! - `VerifyClaims` redesigned with `aud: "email-verification"`. -//! - Per-purpose signing keys (`session_signing_key` and -//! `email_verification_signing_key`). -//! - The `role` and `username` in [`SessionClaims`] are **advisory only**; -//! the authoritative role is re-validated from the database on every -//! authenticated request. +//! - Switched from HMAC-HS256 to RS256 (RSA + SHA-256) asymmetric signing. +//! - Single RSA key pair for all token purposes (session and +//! email-verification); purpose separation is via the `aud` claim. +//! - `EncodingKey` (private) is used only for signing; `DecodingKey` +//! (public) is used only for verification. +//! - A `kid` (Key ID) is included in every JWT header to support +//! future key rotation. +//! - Keys are resolved once at construction time from PEM files or +//! inline PEM config, not on every request. use std::sync::Arc; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::config::Configuration; use crate::errors::AuthError; @@ -68,8 +71,8 @@ pub type UserClaims = SessionClaims; /// Claims embedded in email-verification JWTs. /// -/// Phase 2: now includes `aud: "email-verification"` for purpose -/// separation, and `iss: "torrust-index"`. +/// Includes `aud: "email-verification"` for purpose separation, +/// and `iss: "torrust-index"`. #[derive(Debug, Serialize, Deserialize)] pub struct VerifyClaims { pub iss: String, @@ -83,15 +86,42 @@ pub struct VerifyClaims { /// Centralised JWT signing and verification service. /// -/// Holds a reference to [`Configuration`] so it can read the signing -/// secrets and token lifetimes at runtime. +/// 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 { - pub const fn new(cfg: Arc) -> Self { - Self { cfg } + /// Create a new `JsonWebToken` service, resolving the RSA key pair + /// from the configuration. + /// + /// # Panics + /// + /// Panics if the RSA key PEM cannot be resolved or is invalid. + pub async fn new(cfg: Arc) -> Self { + let settings = cfg.settings.read().await; + let private_pem = settings.auth.resolve_private_key_pem(); + let public_pem = settings.auth.resolve_public_key_pem(); + drop(settings); + + 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. @@ -99,12 +129,13 @@ impl JsonWebToken { /// # Errors /// /// Returns `AuthError::InternalServerError` if the token cannot be - /// encoded (e.g. the encoding key is invalid). + /// encoded. pub async fn sign(&self, user: UserCompact) -> Result { let settings = self.cfg.settings.read().await; - let key = settings.auth.session_signing_key.as_bytes(); 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(), @@ -118,10 +149,11 @@ impl JsonWebToken { }, username: user.username, }; - let result = - encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).map_err(|_| AuthError::InternalServerError); - drop(settings); - result + + 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. @@ -134,52 +166,45 @@ impl JsonWebToken { /// /// * `AuthError::TokenExpired` — the token's `exp` is in the past. /// * `AuthError::TokenInvalid` — signature mismatch or malformed token. - pub async fn verify(&self, token: &str) -> Result { - let settings = self.cfg.settings.read().await; - - let mut validation = Validation::new(Algorithm::HS256); + pub fn verify(&self, token: &str) -> Result { + let mut validation = Validation::new(Algorithm::RS256); validation.set_audience(&["session"]); validation.set_issuer(&[ISSUER]); - let result = decode::( - token, - &DecodingKey::from_secret(settings.auth.session_signing_key.as_bytes()), - &validation, - ) - .map(|token_data| token_data.claims) - .map_err(|e| match e.kind() { - jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired, - _ => AuthError::TokenInvalid, - }); - - drop(settings); - result + 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, + }) } /// Sign an email-verification JWT for the given user ID. /// - /// Uses the dedicated `email_verification_signing_key`. + /// 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 key = settings.auth.email_verification_signing_key.as_bytes(); 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: now + settings.auth.email_verification_token_lifetime_secs, + exp, }; - let result = - encode(&Header::default(), &claims, &EncodingKey::from_secret(key)).map_err(|_| AuthError::InternalServerError); - drop(settings); - result + 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. @@ -190,25 +215,26 @@ impl JsonWebToken { /// /// * `AuthError::TokenExpired` — the token's `exp` is in the past. /// * `AuthError::TokenInvalid` — bad signature, wrong issuer/audience, or malformed. - pub async fn verify_email_token(&self, token: &str) -> Result { - let settings = self.cfg.settings.read().await; - - let mut validation = Validation::new(Algorithm::HS256); + 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]); - let result = decode::( - token, - &DecodingKey::from_secret(settings.auth.email_verification_signing_key.as_bytes()), - &validation, - ) - .map(|token_data| token_data.claims) - .map_err(|e| match e.kind() { - jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired, - _ => AuthError::TokenInvalid, - }); - - drop(settings); - result + 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]) +} diff --git a/src/lib.rs b/src/lib.rs index 769463b40..91e5bb282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,8 +189,8 @@ //! bind_address = "0.0.0.0:3001" //! //! [auth] -//! session_signing_key = "MaxVerstappenWC2021-session-key!" -//! email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +//! private_key_path = "./share/default/jwt/private.pem" +//! public_key_path = "./share/default/jwt/public.pem" //! //! [auth.password_constraints] //! min_password_length = 6 diff --git a/src/services/authentication.rs b/src/services/authentication.rs index 349192982..f97f59cc5 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -110,7 +110,7 @@ impl Service { 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.verify(token)?; let user_compact = self .user_repository diff --git a/src/services/user.rs b/src/services/user.rs index a1ced8ec1..e112e3343 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -186,7 +186,7 @@ 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 Ok(token_data) = self.json_web_token.verify_email_token(token).await else { + let Ok(token_data) = self.json_web_token.verify_email_token(token) else { return Ok(false); }; diff --git a/src/tests/config/mod.rs b/src/tests/config/mod.rs index 15cad45db..91632afa9 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,8 +97,8 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - session_signing_key = "MaxVerstappenWC2021-session-key!" - email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" + private_key_path = "./share/default/jwt/private.pem" + public_key_path = "./share/default/jwt/public.pem" "#, )?; @@ -130,8 +130,8 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - session_signing_key = "MaxVerstappenWC2021-session-key!" - email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" + private_key_path = "./share/default/jwt/private.pem" + public_key_path = "./share/default/jwt/public.pem" "# .to_string(); @@ -172,14 +172,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_session_signing_key_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__SESSION_SIGNING_KEY", - "OVERRIDDEN-SESSION-SIGNING-SECRET!", + "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH", + "/custom/path/private.pem", ); let info = Info { @@ -189,10 +189,7 @@ async fn configuration_should_allow_to_override_the_session_signing_key_provided let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); - assert_eq!( - settings.auth.session_signing_key, - SecretKey::new("OVERRIDDEN-SESSION-SIGNING-SECRET!") - ); + 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 321bba203..0f59ddd14 100644 --- a/src/tests/config/v2/auth.rs +++ b/src/tests/config/v2/auth.rs @@ -1,19 +1,42 @@ -use crate::config::v2::auth::JwtSigningSecret; +//! 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_panics_when_no_key` — panics if no key is available. + +use crate::config::v2::auth::Auth; #[test] -#[should_panic(expected = "secret key cannot be empty")] -fn secret_key_can_not_be_empty() { - drop(JwtSigningSecret::new("")); +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(); + assert!(pem.starts_with(b"-----BEGIN PRIVATE KEY-----")); } #[test] -#[should_panic(expected = "secret key must be at least 32 bytes")] -fn secret_key_must_meet_minimum_length() { - drop(JwtSigningSecret::new("too-short")); +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(); + assert!(pem.starts_with(b"-----BEGIN PUBLIC KEY-----")); } #[test] -fn secret_key_accepts_valid_length() { - let key = JwtSigningSecret::new("a]32-byte-minimum-length-secret!"); - assert_eq!(key.as_bytes().len(), 32); +#[should_panic(expected = "No RSA private key configured")] +fn resolve_private_key_pem_panics_when_no_key() { + let auth = Auth { + private_key_pem: None, + private_key_path: None, + ..Auth::default() + }; + drop(auth.resolve_private_key_pem()); } diff --git a/src/web/api/client/v1/contexts/settings/mod.rs b/src/web/api/client/v1/contexts/settings/mod.rs index 375ecd6ca..a3229f261 100644 --- a/src/web/api/client/v1/contexts/settings/mod.rs +++ b/src/web/api/client/v1/contexts/settings/mod.rs @@ -49,8 +49,8 @@ pub struct Network { #[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] pub struct Auth { - pub session_signing_key: String, - pub email_verification_signing_key: String, + pub private_key_path: Option, + pub public_key_path: Option, pub password_constraints: PasswordConstraints, } @@ -153,8 +153,8 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { Self { - session_signing_key: auth.session_signing_key.to_string(), - email_verification_signing_key: auth.email_verification_signing_key.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 9201738ae..0aee52887 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -132,8 +132,8 @@ impl Authentication { /// # 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 + pub fn verify_jwt(&self, token: &str) -> Result { + self.json_web_token.verify(token) } /// Get logged-in user ID from bearer token @@ -141,8 +141,8 @@ impl Authentication { /// # 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?; + pub fn get_user_id_from_bearer_token(&self, maybe_token: Option) -> Result { + let claims = self.get_claims_from_bearer_token(maybe_token)?; Ok(claims.sub) } @@ -154,14 +154,8 @@ impl Authentication { /// /// - 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), - } + fn get_claims_from_bearer_token(&self, maybe_token: Option) -> Result { + maybe_token.map_or(Err(AuthError::TokenNotFound), |token| self.verify_jwt(&token.value())) } } @@ -188,15 +182,11 @@ pub fn parse_token(authorization: &HeaderValue) -> Result { /// # Errors /// /// It returns an error if we cannot get the user from the bearer token. -pub async fn get_optional_logged_in_user( +pub fn get_optional_logged_in_user( maybe_bearer_token: Option, - app_data: Arc, + 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), - } + maybe_bearer_token.map_or(Ok(None), |bearer_token| { + app_data.auth.get_user_id_from_bearer_token(Some(bearer_token)).map(Some) + }) } diff --git a/src/web/api/server/v1/contexts/settings/mod.rs b/src/web/api/server/v1/contexts/settings/mod.rs index 91e8e2cff..33ed9517d 100644 --- a/src/web/api/server/v1/contexts/settings/mod.rs +++ b/src/web/api/server/v1/contexts/settings/mod.rs @@ -54,8 +54,10 @@ //! "tsl": null //! }, //! "auth": { -//! "session_signing_key": "***", -//! "email_verification_signing_key": "***", +//! "private_key_pem": "***-redacted-private-key-pem***", +//! "public_key_pem": null, +//! "private_key_path": "***-redacted***", +//! "public_key_path": "./share/default/jwt/public.pem", //! "session_token_lifetime_secs": 1209600, //! "email_verification_token_lifetime_secs": 315569260, //! "password_constraints": { diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index 4a67c4068..9a4c27fd2 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -100,7 +100,7 @@ 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.verify(&token.token) { Ok(_) => axum::Json(OkResponseData { data: "Token is valid.".to_string(), }) diff --git a/src/web/api/server/v1/contexts/user/mod.rs b/src/web/api/server/v1/contexts/user/mod.rs index a551afc0f..220592be9 100644 --- a/src/web/api/server/v1/contexts/user/mod.rs +++ b/src/web/api/server/v1/contexts/user/mod.rs @@ -45,8 +45,8 @@ //! //! ```toml //! [auth] -//! session_signing_key = "your-session-signing-secret-here!" -//! email_verification_signing_key = "your-email-verify-secret-here!!" +//! 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) 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..ab31acfc2 100644 --- a/src/web/api/server/v1/extractors/optional_user_id.rs +++ b/src/web/api/server/v1/extractors/optional_user_id.rs @@ -27,7 +27,7 @@ where 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 { + let result = match app_data.auth.get_user_id_from_bearer_token(bearer_token) { Ok(user_id) => Ok(Self(Some(user_id))), Err(_) => Ok(Self(None)), }; diff --git a/src/web/api/server/v1/extractors/user_id.rs b/src/web/api/server/v1/extractors/user_id.rs index a0c203a7e..e6877c08b 100644 --- a/src/web/api/server/v1/extractors/user_id.rs +++ b/src/web/api/server/v1/extractors/user_id.rs @@ -28,7 +28,7 @@ where 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 { + let result = match app_data.auth.get_user_id_from_bearer_token(maybe_bearer_token) { Ok(user_id) => Ok(Self(user_id)), Err(_) => Err(AuthError::LoggedInUserNotFound.into_response()), }; diff --git a/tests/common/contexts/settings/mod.rs b/tests/common/contexts/settings/mod.rs index 570015093..8e199344c 100644 --- a/tests/common/contexts/settings/mod.rs +++ b/tests/common/contexts/settings/mod.rs @@ -55,8 +55,8 @@ pub struct Network { #[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] pub struct Auth { - pub session_signing_key: String, - pub email_verification_signing_key: String, + pub private_key_path: Option, + pub public_key_path: Option, pub password_constraints: PasswordConstraints, } @@ -180,8 +180,8 @@ impl From for Network { impl From for Auth { fn from(auth: DomainAuth) -> Self { Self { - session_signing_key: auth.session_signing_key.to_string(), - email_verification_signing_key: auth.email_verification_signing_key.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 66a22ab0d..71f691d0b 100644 --- a/tests/e2e/environment.rs +++ b/tests/e2e/environment.rs @@ -114,8 +114,7 @@ impl TestEnv { "***".clone_into(&mut settings.mail.smtp.credentials.password); - "***-redacted-session-signing-key!".clone_into(&mut settings.auth.session_signing_key); - "***-redacted-emailverify-secret!".clone_into(&mut settings.auth.email_verification_signing_key); + settings.auth.private_key_path = Some("***-redacted***".to_owned()); Some(settings) } diff --git a/tests/fixtures/default_configuration.toml b/tests/fixtures/default_configuration.toml index b53ffbbb8..31694434e 100644 --- a/tests/fixtures/default_configuration.toml +++ b/tests/fixtures/default_configuration.toml @@ -44,8 +44,8 @@ url = "udp://localhost:6969" bind_address = "0.0.0.0:3001" [auth] -session_signing_key = "MaxVerstappenWC2021-session-key!" -email_verification_signing_key = "MaxVerstappenWC2021-emailverify!" +private_key_path = "./share/default/jwt/private.pem" +public_key_path = "./share/default/jwt/public.pem" [auth.password_constraints] max_password_length = 64 From 4adbe3c516da10d96acfa10f846dbcbd9388eea2 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Wed, 15 Apr 2026 07:24:18 +0200 Subject: [PATCH 04/10] feat(jwt): add token revocation via per-user generation counter (ADR-T-007 Phase 4) Add a `token_generation` column to `torrust_users` and embed a `gen` claim in every session JWT. Password changes, admin-role grants, and bans increment the counter, instantly invalidating all outstanding tokens for that user. Revocation is checked at three entry points (defence in depth): - `Authentication::get_user_id_from_bearer_token` - `verify_token_handler` - `authentication::Service::renew_token` Rework the `BearerToken` extractor to reject missing or malformed `Authorization` headers at the extraction boundary instead of deferring the check downstream. Remove the `bearer_token::Extract` wrapper and `get_optional_logged_in_user` free function; the logic now lives directly in the extractors. Add `AuthError::TokenRevoked` for revoked-token responses. Add crate tests for the JWT module (sign/verify round-trips, audience cross-contamination, tampered tokens) and for `parse_token` (whitespace trimming, empty bearer, non-ASCII rejection). --- CHANGELOG.md | 53 ++++++ adr/007-jwt-system-refactor.md | 57 ++++-- ...14000000_torrust_user_token_generation.sql | 1 + ...14000000_torrust_user_token_generation.sql | 1 + src/app.rs | 3 +- src/config/v2/auth.rs | 9 +- src/databases/database.rs | 7 + src/databases/mysql.rs | 24 +++ src/databases/sqlite.rs | 24 +++ src/errors.rs | 4 + src/jwt.rs | 38 ++-- src/services/authentication.rs | 49 ++++- src/services/user.rs | 24 ++- src/tests/jwt.rs | 177 ++++++++++++++++++ src/tests/mod.rs | 2 + src/tests/web/auth.rs | 58 ++++++ src/tests/web/mod.rs | 1 + src/web/api/server/v1/auth.rs | 76 ++++---- .../api/server/v1/contexts/user/handlers.rs | 26 ++- .../api/server/v1/extractors/bearer_token.rs | 36 ++-- .../server/v1/extractors/optional_user_id.rs | 16 +- src/web/api/server/v1/extractors/user_id.rs | 22 +-- 22 files changed, 584 insertions(+), 124 deletions(-) create mode 100644 migrations/mysql/20260414000000_torrust_user_token_generation.sql create mode 100644 migrations/sqlite3/20260414000000_torrust_user_token_generation.sql create mode 100644 src/tests/jwt.rs create mode 100644 src/tests/web/auth.rs create mode 100644 src/tests/web/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 639fc3edc..47cf5c4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,60 @@ 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`). +- Development RSA key pair shipped at `share/default/jwt/` with loud startup + warning when the default dev keys are detected. +- `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. +- Revocation checks at three entry points (defence in depth): + `Authentication::get_user_id_from_bearer_token`, `verify_token_handler`, + and `authentication::Service::renew_token`. +- `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 +76,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/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index d07a9c487..bd797ac75 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -1,6 +1,6 @@ # ADR-T-007: Refactor the JWT System -**Status:** Phase 3 implemented +**Status:** Phase 4 implemented **Date:** 2026-04-14 ## Context @@ -340,9 +340,8 @@ phased rollout that subsumes Options A and B. does not currently need instant revocation badly enough to justify the infrastructure cost and loss of statelessness. - **Option E (hybrid revocation):** The `token_generation` column - approach is elegant but adds complexity that can be layered on - later without changing the token format. It remains a valid - follow-up if revocation becomes a priority. + approach was originally deferred but has since been implemented + in Phase 4. See the Phase 4 section below. ### Implementation Phases @@ -384,7 +383,9 @@ phased rollout that subsumes Options A and B. authenticated request (the authorization service already does this via `get_role`) so the token role is advisory only. - ✅ Enforce a minimum secret length (32 bytes) at config - validation time. + validation time. *(With Phase 3's move to RS256, this is now + enforced implicitly: `EncodingKey::from_rsa_pem` / + `DecodingKey::from_rsa_pem` reject invalid PEM at startup.)* - **Breaking change:** existing HS256 tokens are invalidated; users must re-login. @@ -408,14 +409,23 @@ phased rollout that subsumes Options A and B. no longer supported. Deployers must generate an RSA key pair and update their configuration. -#### Future — Optional Revocation (Option E scope) - -- Add a `token_generation` column to `torrust_users`. -- Include `gen` in `SessionClaims`; reject stale generations on - verify. -- Increment generation on password change, role change, or ban. -- This phase is independent and can be shipped whenever revocation - becomes a priority. +#### Phase 4 — Optional Revocation (Option E scope) ✅ Implemented + +- ✅ Add a `token_generation` column (default `0`) to + `torrust_users`. +- ✅ Include `gen` in `SessionClaims`; reject tokens whose `gen` + is older than the current database value. +- ✅ Increment `token_generation` on password change, role change + (admin grant), and ban. +- ✅ Validation performed in the `Authentication` web layer + (`get_user_id_from_bearer_token`), the `verify_token_handler`, + and the `renew_token` service method. + *Defence in depth:* the generation check is intentionally + repeated at each entry point rather than consolidated into a + single layer, so that no call path can accidentally bypass + revocation. +- **Breaking change:** existing tokens without a `gen` claim will + fail deserialization and be rejected (users re-login once). ### Configuration Migration @@ -438,10 +448,25 @@ A migration guide will accompany the release that ships Phase 3. - Deployers must generate and manage an RSA key pair (Phase 3). A development-mode auto-generated key reduces friction for local setups. -- Token revocation is **not** included in the initial scope but - the architecture cleanly supports adding it later (Phase 4 / - Option E). +- Token revocation via a `token_generation` counter is included + (Phase 4 / Option E). Password changes, role changes, and bans + increment the counter and invalidate outstanding tokens. - The centralised `jwt` module makes future algorithm changes (e.g., migrating to EdDSA) a localised, single-module change. - External services can verify tokens using only the public key, enabling zero-trust verification without secret sharing. + +## Remaining Issues + +- **Problem #11 (`BearerToken` extractor returns `Ok(None)`).** + ✅ **Resolved.** The `BearerToken` extractor now implements + `FromRequestParts` directly and **rejects** missing + (`AuthError::TokenNotFound`) or malformed + (`AuthError::TokenInvalid`) `Authorization` headers at the + extraction boundary. The `Extract` wrapper has been removed. + `ExtractLoggedInUser` uses `BearerToken` directly (fails if + missing). `ExtractOptionalLoggedInUser` catches the rejection + and returns `None` for anonymous requests. + `Authentication::get_user_id_from_bearer_token` now takes + `BearerToken` (not `Option`), eliminating the + `None`-handling indirection. 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/src/app.rs b/src/app.rs index 00b8830a8..df6c4ebb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -74,7 +74,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let database = Arc::new(database::connect(&database_connect_url).await.expect("Database error.")); let json_web_token = Arc::new(JsonWebToken::new(configuration.clone()).await); - let auth = Arc::new(Authentication::new(json_web_token.clone())); + let auth = Arc::new(Authentication::new(json_web_token.clone(), database.clone())); // Repositories let category_repository = Arc::new(DbCategoryRepository::new(database.clone())); @@ -154,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/config/v2/auth.rs b/src/config/v2/auth.rs index 8e31aa07a..94b690b3e 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -15,10 +15,13 @@ const DEFAULT_PUBLIC_KEY_PATH: &str = "./share/default/jwt/public.pem"; /// Authentication options. /// -/// ## Phase 3 (ADR-T-007) +/// ## JWT signing (ADR-T-007) /// -/// JWT signing has moved from HMAC-HS256 with shared secrets to -/// RS256 (RSA + SHA-256) with a public/private key pair. +/// 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): /// diff --git a/src/databases/database.rs b/src/databases/database.rs index 0e606c94c..49d81dc3d 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -238,6 +238,13 @@ pub trait Database: Sync + Send { /// Grant a user the administrator role. async fn grant_admin_role(&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..49552ee56 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -299,6 +299,30 @@ impl Database for Mysql { }) } + 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(|(v,): (i64,)| u64::try_from(v).unwrap_or(0)) + .map_err(|_| database::Error::UserNotFound) + } + + 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..3b51faa94 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -295,6 +295,30 @@ impl Database for Sqlite { }) } + 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(|(v,): (i64,)| u64::try_from(v).unwrap_or(0)) + .map_err(|_| database::Error::UserNotFound) + } + + 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 index 82de4dcf0..64be1a7d0 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -5,17 +5,27 @@ //! //! See ADR-T-007 for the rationale behind centralising JWT handling. //! -//! ## Phase 3 changes (ADR-T-007) +//! # Architecture (ADR-T-007 Phases 1–4) //! -//! - Switched from HMAC-HS256 to RS256 (RSA + SHA-256) asymmetric signing. -//! - Single RSA key pair for all token purposes (session and -//! email-verification); purpose separation is via the `aud` claim. -//! - `EncodingKey` (private) is used only for signing; `DecodingKey` -//! (public) is used only for verification. -//! - A `kid` (Key ID) is included in every JWT header to support -//! future key rotation. -//! - Keys are resolved once at construction time from PEM files or -//! inline PEM config, not on every request. +//! **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. use std::sync::Arc; @@ -61,6 +71,11 @@ pub struct SessionClaims { pub role: String, /// Advisory username. Non-authoritative. pub username: String, + /// Token generation counter. Tokens with a `gen` older than the + /// current database value for this user are considered revoked. + /// See ADR-T-007 Phase 4 (Optional Revocation). + #[serde(rename = "gen")] + pub token_gen: u64, } /// Backward-compatible type alias. @@ -130,7 +145,7 @@ impl JsonWebToken { /// /// Returns `AuthError::InternalServerError` if the token cannot be /// encoded. - pub async fn sign(&self, user: UserCompact) -> Result { + 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; @@ -148,6 +163,7 @@ impl JsonWebToken { "user".to_owned() }, username: user.username, + token_gen: token_generation, }; let mut header = Header::new(Algorithm::RS256); diff --git a/src/services/authentication.rs b/src/services/authentication.rs index f97f59cc5..ecb34b830 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -1,4 +1,14 @@ //! 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 Phase 4) +//! +//! On login and renewal, the service fetches the user's current +//! `token_generation` from the database and embeds it in the JWT +//! (`gen` claim). On renewal, tokens whose `gen` is older than the +//! database value are rejected as revoked. use std::sync::Arc; use argon2::{Argon2, PasswordHash, PasswordVerifier}; @@ -18,6 +28,7 @@ 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, @@ -27,6 +38,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, @@ -34,6 +46,7 @@ impl Service { Self { configuration, json_web_token, + database, user_repository, user_profile_repository, user_authentication_repository, @@ -92,8 +105,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)) } @@ -112,18 +128,21 @@ impl Service { // Verify if token is valid let claims = self.json_web_token.verify(token)?; - let user_compact = self - .user_repository - .get_compact(&claims.sub) - .await - .map_err(|err| match err { - Error::UserNotFound => AuthError::UserNotFound, - err => AuthError::from(err), - })?; + // Validate token generation — reject revoked tokens + let current_gen = self.database.get_token_generation(claims.sub).await?; + + if claims.token_gen < current_gen { + return Err(AuthError::TokenRevoked); + } + + 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?, + x if x < ONE_WEEK_IN_SECONDS => self.json_web_token.sign(user_compact.clone(), current_gen).await?, _ => token.to_string(), }; @@ -159,6 +178,16 @@ 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 } + + /// 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 e112e3343..ed37e05dd 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -275,6 +275,11 @@ impl ProfileService { .change_password(user_id, &password_hash) .await?; + // Invalidate all outstanding session tokens for this user + self.user_authentication_repository + .increment_token_generation(user_id) + .await?; + Ok(()) } } @@ -324,6 +329,11 @@ impl BanService { self.banned_user_list.add(&user_profile.user_id).await?; + // Invalidate all outstanding session tokens for the banned user + self.banned_user_list + .increment_token_generation(&user_profile.user_id) + .await?; + Ok(()) } } @@ -465,7 +475,9 @@ 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 + self.database.grant_admin_role(*user_id).await?; + // Invalidate outstanding session tokens — the user's role changed. + self.database.increment_token_generation(*user_id).await } /// It deletes the user. @@ -567,6 +579,16 @@ impl DbBannedUserList { self.database.ban_user(*user_id, &reason, date_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 + } } fn validate_password_constraints( diff --git a/src/tests/jwt.rs b/src/tests/jwt.rs new file mode 100644 index 000000000..4a48dc80b --- /dev/null +++ b/src/tests/jwt.rs @@ -0,0 +1,177 @@ +//! 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 backed by the development RSA key +/// pair shipped at `share/default/jwt/`. +/// +/// Uses absolute paths derived from `CARGO_MANIFEST_DIR` so the tests +/// are immune to working-directory changes from parallel tests. +async fn jwt_service() -> JsonWebToken { + let cfg = Arc::new(Configuration::default()); + + // Override the key paths with absolute paths so that tests do not + // depend on the current working directory. + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + { + let mut settings = cfg.settings.write().await; + settings.auth.private_key_path = Some(format!("{manifest_dir}/share/default/jwt/private.pem")); + settings.auth.public_key_path = Some(format!("{manifest_dir}/share/default/jwt/public.pem")); + } + + 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/server/v1/auth.rs b/src/web/api/server/v1/auth.rs index 0aee52887..fdfb4f042 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -42,14 +42,15 @@ //! ```json //! { //! "data":{ -//! "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak", +//! "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImExYjJjM2Q0ZTVmNmE3YjgifQ.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImdlbiI6MH0.RS256-SIGNATURE", //! "username":"indexadmin", //! "admin":true //! } //! } //! ``` //! -//! The JWT payload contains RFC 7519 registered claims: +//! The JWT is signed with RS256 (RSA + SHA-256). The payload contains +//! RFC 7519 registered claims plus advisory fields: //! //! ```json //! { @@ -59,7 +60,8 @@ //! "iat": 1686215788, //! "exp": 1687425388, //! "role": "admin", -//! "username": "indexadmin" +//! "username": "indexadmin", +//! "gen": 0 //! } //! ``` //! @@ -67,6 +69,11 @@ //! 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 (see ADR-T-007 Phase 4). +//! //! **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. @@ -85,7 +92,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImExYjJjM2Q0ZTVmNmE3YjgifQ.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiIsImdlbiI6MH0.RS256-SIGNATURE" \ //! --request POST \ //! --data '{"name":"new category","icon":null}' \ //! http://127.0.0.1:3001/v1/category @@ -102,7 +109,7 @@ use std::sync::Arc; use hyper::http::HeaderValue; -use crate::common::AppData; +use crate::databases::database::Database; use crate::errors::AuthError; use crate::jwt::{JsonWebToken, SessionClaims}; use crate::models::user::{UserCompact, UserId}; @@ -110,12 +117,16 @@ 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 @@ -123,8 +134,8 @@ impl Authentication { /// # Errors /// /// Returns `AuthError::InternalServerError` if the token cannot be encoded. - pub async fn sign_jwt(&self, user: UserCompact) -> Result { - self.json_web_token.sign(user).await + pub async fn sign_jwt(&self, user: UserCompact, token_generation: u64) -> Result { + self.json_web_token.sign(user, token_generation).await } /// Verify Json Web Token @@ -136,26 +147,31 @@ impl Authentication { self.json_web_token.verify(token) } - /// Get logged-in user ID 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 error if it can get claims from the request - pub fn get_user_id_from_bearer_token(&self, maybe_token: Option) -> Result { - let claims = self.get_claims_from_bearer_token(maybe_token)?; + /// 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.verify(&token.value())?; + self.validate_token_generation(&claims).await?; Ok(claims.sub) } - /// Get Claims from bearer token - /// - /// # Errors - /// - /// This function will: - /// - /// - Return an `AuthError::TokenNotFound` if `HeaderValue` is `None`. - /// - Pass through the `AuthError::TokenInvalid` if unable to verify the JWT. - fn get_claims_from_bearer_token(&self, maybe_token: Option) -> Result { - maybe_token.map_or(Err(AuthError::TokenNotFound), |token| self.verify_jwt(&token.value())) + /// Checks that the token's `gen` claim matches the current + /// `token_generation` in the database. Returns `AuthError::TokenRevoked` + /// if the token is stale (e.g. after a password change, role change, + /// or ban). + async fn validate_token_generation(&self, claims: &SessionClaims) -> Result<(), AuthError> { + let current_gen = self.database.get_token_generation(claims.sub).await?; + + if claims.token_gen < current_gen { + return Err(AuthError::TokenRevoked); + } + + Ok(()) } } @@ -176,17 +192,3 @@ pub fn parse_token(authorization: &HeaderValue) -> Result { Ok(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 fn get_optional_logged_in_user( - maybe_bearer_token: Option, - app_data: &Arc, -) -> Result, AuthError> { - maybe_bearer_token.map_or(Ok(None), |bearer_token| { - app_data.auth.get_user_id_from_bearer_token(Some(bearer_token)).map(Some) - }) -} diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index 9a4c27fd2..233d424ef 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm}; use super::responses::{self}; use crate::common::AppData; +use crate::errors::AuthError; use crate::services::user::ListingRequest; use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; use crate::web::api::server::v1::responses::OkResponseData; @@ -95,18 +96,29 @@ pub async fn login_handler( /// /// - Unable to verify the supplied payload as a valid JWT. /// - The JWT is not invalid or expired. -#[allow(clippy::unused_async)] +/// - 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) { - Ok(_) => axum::Json(OkResponseData { - data: "Token is valid.".to_string(), - }) - .into_response(), - Err(error) => error.into_response(), + let claims = match app_data.json_web_token.verify(&token.token) { + Ok(claims) => claims, + Err(error) => return error.into_response(), + }; + + // Validate token generation against the database + let Ok(current_gen) = app_data.database.get_token_generation(claims.sub).await else { + return AuthError::UserNotFound.into_response(); + }; + + if claims.token_gen < current_gen { + return AuthError::TokenRevoked.into_response(); } + + axum::Json(OkResponseData { + data: "Token is valid.".to_string(), + }) + .into_response() } #[derive(Deserialize)] diff --git a/src/web/api/server/v1/extractors/bearer_token.rs b/src/web/api/server/v1/extractors/bearer_token.rs index f60511bfe..f463cfe1c 100644 --- a/src/web/api/server/v1/extractors/bearer_token.rs +++ b/src/web/api/server/v1/extractors/bearer_token.rs @@ -1,13 +1,24 @@ 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 { @@ -17,19 +28,20 @@ impl BearerToken { } } -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(parse_token(header_value).ok().map(BearerToken))), - 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 ab31acfc2..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) { - 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 e6877c08b..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) { - 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) } } From 7c8ca72d6e71baeb05d134b53e33085d1c7f9e2e Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Wed, 15 Apr 2026 18:43:36 +0200 Subject: [PATCH 05/10] fix(e2e): provision JWT PEM keys and fix health-check script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy the RS256 key pair into the volume-mounted storage path during E2E install for both SQLite and MySQL setups, aligning the container environment with the HMAC→RS256 migration (ADR-T-007 Phase 3). Also make `wait_for_container_to_be_healthy.sh` directly invocable by forwarding arguments to the function it defines. --- contrib/dev-tools/container/e2e/mysql/install.sh | 4 ++++ contrib/dev-tools/container/e2e/sqlite/install.sh | 4 ++++ .../container/functions/wait_for_container_to_be_healthy.sh | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/contrib/dev-tools/container/e2e/mysql/install.sh b/contrib/dev-tools/container/e2e/mysql/install.sh index 5cbb5a09d..e103d0265 100755 --- a/contrib/dev-tools/container/e2e/mysql/install.sh +++ b/contrib/dev-tools/container/e2e/mysql/install.sh @@ -16,6 +16,10 @@ MYSQL_DATABASE=$TORRUST_INDEX_DATABASE echo "Creating MySQL database '$MYSQL_DATABASE' for for E2E testing ..." MYSQL_PWD=$MYSQL_PASSWORD mysql -h $MYSQL_HOST -u $MYSQL_USER -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE;" +# Copy JWT PEM keys into the volume-mounted path +mkdir -p ./storage/index/lib/jwt +cp ./share/default/jwt/private.pem ./share/default/jwt/public.pem ./storage/index/lib/jwt/ + ## Tracker # Generate the Tracker sqlite database directory and file if it does not exist diff --git a/contrib/dev-tools/container/e2e/sqlite/install.sh b/contrib/dev-tools/container/e2e/sqlite/install.sh index 24bb5cd7b..e24866ed1 100755 --- a/contrib/dev-tools/container/e2e/sqlite/install.sh +++ b/contrib/dev-tools/container/e2e/sqlite/install.sh @@ -12,6 +12,10 @@ if ! [ -f "./storage/index/lib/database/${TORRUST_INDEX_DATABASE}.db" ]; then sqlite3 "./storage/index/lib/database/${TORRUST_INDEX_DATABASE}.db" "VACUUM;" fi +# Copy JWT PEM keys into the volume-mounted path +mkdir -p ./storage/index/lib/jwt +cp ./share/default/jwt/private.pem ./share/default/jwt/public.pem ./storage/index/lib/jwt/ + ## Tracker # Generate the Tracker sqlite database directory and file if it does not exist 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 From 4183c129d1acc47b56dd1f259a7a2523f11840ae Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Wed, 15 Apr 2026 20:14:11 +0200 Subject: [PATCH 06/10] feat(jwt): auto-generate ephemeral RSA keys when none configured (ADR-T-007 Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the development RSA key pair shipped at `share/default/jwt/` and replace it with on-demand ephemeral key generation. When no `private_key_path`/`public_key_path` or inline PEM values are configured, the server auto-generates an RSA-2048 key pair in memory at startup via the `rsa` crate. Sessions work immediately but do not survive restarts — deployers who want persistent sessions supply their own key pair. Key changes: - `Auth::resolve_{private,public}_key_pem()` now return `Option` instead of panicking, letting `JsonWebToken::new()` decide whether to use host-supplied or ephemeral keys. - `JsonWebToken::new()` matches on (Some, Some) / (None, None) and panics on mismatched key configuration. - `generate_ephemeral_key_pair()` offloads CPU-heavy RSA generation to a blocking Tokio task. - Default config no longer includes key paths; all container configs, e2e scripts, compose.yaml, and docs updated accordingly. - Tests now exercise the ephemeral code path by default. - ADR-T-007 updated with Phase 5/6 design, migration notes, and testing strategy. --- .env.local | 2 - CHANGELOG.md | 5 +- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 20 +- adr/007-jwt-system-refactor.md | 364 +++++++++++++++++- compose.yaml | 2 - .../dev-tools/container/e2e/mysql/install.sh | 4 - .../dev-tools/container/e2e/sqlite/install.sh | 4 - .../e2e/sqlite/mode/private/e2e-env-up.sh | 2 - .../e2e/sqlite/mode/public/e2e-env-up.sh | 2 - docs/containers.md | 14 +- .../default/config/index.container.mysql.toml | 2 - .../config/index.container.sqlite3.toml | 2 - .../config/index.development.sqlite3.toml | 2 - .../index.private.e2e.container.sqlite3.toml | 2 - .../index.public.e2e.container.mysql.toml | 2 - .../index.public.e2e.container.sqlite3.toml | 2 - ...tracker.private.e2e.container.sqlite3.toml | 2 - .../tracker.public.e2e.container.sqlite3.toml | 2 - share/default/jwt/private.pem | 28 -- share/default/jwt/public.pem | 9 - src/config/v2/auth.rs | 79 ++-- src/jwt.rs | 84 +++- src/lib.rs | 2 - src/tests/config/mod.rs | 4 - src/tests/config/v2/auth.rs | 12 +- src/tests/jwt.rs | 17 +- .../api/server/v1/contexts/settings/mod.rs | 6 +- src/web/api/server/v1/contexts/user/mod.rs | 5 +- tests/fixtures/default_configuration.toml | 2 - 31 files changed, 505 insertions(+), 180 deletions(-) delete mode 100644 share/default/jwt/private.pem delete mode 100644 share/default/jwt/public.pem diff --git a/.env.local b/.env.local index b2c2a023d..f9bc3983b 100644 --- a/.env.local +++ b/.env.local @@ -1,7 +1,5 @@ DATABASE_URL=sqlite://storage/database/data.db?mode=rwc TORRUST_INDEX_CONFIG_TOML= -TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH=./share/default/jwt/private.pem -TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH=./share/default/jwt/public.pem USER_ID=1000 TORRUST_TRACKER_CONFIG_TOML= TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=Sqlite3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 47cf5c4f5..65f7501c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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`). -- Development RSA key pair shipped at `share/default/jwt/` with loud startup - warning when the default dev keys are detected. +- 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. - `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). diff --git a/Cargo.lock b/Cargo.lock index fc332f664..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", diff --git a/Cargo.toml b/Cargo.toml index 31adb8a9f..b6b2a73fb 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" diff --git a/README.md b/README.md index 357c3dd85..2ff4d5329 100644 --- a/README.md +++ b/README.md @@ -93,18 +93,26 @@ _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: +_For deployment, you __should__ override the `tracker_api_token`:_ -- The `tracker_api_token` and RSA key paths by using environmental variables:_ +```sh +# 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") \ + 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 +openssl genrsa -out private.pem 2048 +openssl rsa -in private.pem -pubout -out public.pem -# Override secrets in configuration using environmental variables +# Supply key paths via environment 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__PRIVATE_KEY_PATH="/path/to/private.pem" \ TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/path/to/public.pem" \ cargo run diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index bd797ac75..3b029bd56 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -1,6 +1,6 @@ # ADR-T-007: Refactor the JWT System -**Status:** Phase 4 implemented +**Status:** Implemented **Date:** 2026-04-14 ## Context @@ -397,8 +397,10 @@ phased rollout that subsumes Options A and B. - `auth.public_key_path` for verification. - Alternatively, inline PEM via environment variable (`auth.private_key_pem`, `auth.public_key_pem`). -- ✅ Development key pair shipped at `share/default/jwt/` with loud - startup warning when the default dev keys are used. +- ~~Development key pair shipped at `share/default/jwt/` with loud + startup warning when the default dev keys are used.~~ + *(Implemented, then superseded by Phase 5 — ephemeral + auto-generated keys replace the shipped dev keys.)* - ✅ Use `EncodingKey::from_rsa_pem` / `DecodingKey::from_rsa_pem`. - ✅ Only the signing service loads the private key; the verification path uses the public key. @@ -427,12 +429,249 @@ phased rollout that subsumes Options A and B. - **Breaking change:** existing tokens without a `gen` claim will fail deserialization and be rejected (users re-login once). +#### Phase 5 — Ephemeral Auto-Generated Keys (default) ✅ Implemented + +##### Behaviour + +- Remove the shipped development key pair from `share/default/jwt/`. +- On startup, if no key paths or PEM values are configured, + **auto-generate an RSA-2048 key pair in memory** via the `rsa` + crate's `RsaPrivateKey::new(&mut OsRng, 2048)`. +- The generated keys are held only in process memory and are + **never written to disk**. On shutdown (or crash) the keys are + lost; all outstanding tokens become unverifiable and users must + re-login. +- Log a clear informational message at startup: + `"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."` +- No development-mode keys exist in the repository. There is no + distinction between "dev" and "prod" key material — only + between ephemeral (default) and host-supplied (persistent). +- For **persistent sessions across restarts**, the deployer + generates their own RSA key pair and configures the paths or + environment variables as described in Phase 3. +- **No breaking change for existing Phase 3 deployers** who + already supply their own key pair — their configuration + continues to work as before. Only the *default* behaviour + changes (from shipped dev keys to ephemeral keys). + +##### New dependencies + +The `rsa` crate (already a transitive dependency via +`jsonwebtoken`'s `rust_crypto` feature) must be added as a +**direct** dependency in `Cargo.toml` along with `rand` (for +`OsRng`). PEM export requires the `pkcs8` + `pem` features on +`rsa` (for `EncodePrivateKey::to_pkcs8_pem`) and `spki` (for +`EncodePublicKey::to_public_key_pem`). + +##### Key generation details + +`RsaPrivateKey::new()` is CPU-intensive (~100-300 ms for 2048 +bits). Because `JsonWebToken::new()` is `async`, the generation +must be wrapped in `tokio::task::spawn_blocking` to avoid stalling +the async executor. + +After generation, the private and public keys are exported to +in-memory PEM byte vectors via: +```rust +use rsa::pkcs8::EncodePrivateKey; +use rsa::pkcs8::LineEnding; +use spki::EncodePublicKey; + +let private_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .expect("PEM export"); +let public_pem = private_key + .to_public_key() + .to_public_key_pem(LineEnding::LF) + .expect("PEM export"); +``` +These PEM bytes are then passed to `EncodingKey::from_rsa_pem` / +`DecodingKey::from_rsa_pem` exactly as the host-supplied path does +today. + +##### Interface changes + +- **`Auth::resolve_private_key_pem()` / `resolve_public_key_pem()`** + currently **panic** when no key is found. These methods must + change their return type to `Option>` so the caller + (`JsonWebToken::new`) can distinguish "no key configured" from + "key configured but invalid". +- **`Auth::default()`** must set `private_key_path` and + `public_key_path` to `None` (not the former + `./share/default/jwt/…` paths). The `DEFAULT_PRIVATE_KEY_PATH` + and `DEFAULT_PUBLIC_KEY_PATH` constants are removed. +- **`JsonWebToken::new()`** gains a new branch: when both + `resolve_*` methods return `None`, it generates an ephemeral + key pair (via `spawn_blocking`) and logs the informational + message. + +##### Files affected by dev-key removal + +Removing `share/default/jwt/` and the default-path constants +touches the following files (non-exhaustive): + +| File | Change | +|---|---| +| `share/default/jwt/private.pem`, `public.pem` | Delete | +| `src/config/v2/auth.rs` | Remove `DEFAULT_*_KEY_PATH` constants; change defaults to `None`; return `Option` from `resolve_*` | +| `src/jwt.rs` | Add ephemeral-generation branch in `JsonWebToken::new()` | +| `src/lib.rs` | Update doc-comment example config (remove key paths from default) | +| `src/tests/jwt.rs` | `jwt_service()` helper uses ephemeral path (no path overrides) | +| `tests/fixtures/default_configuration.toml` | Remove `private_key_path` / `public_key_path` lines | +| `.env.local` | Remove `AUTH__PRIVATE_KEY_PATH` / `AUTH__PUBLIC_KEY_PATH` overrides | +| `share/default/config/index.development.sqlite3.toml` | Remove key-path lines | +| `contrib/dev-tools/container/e2e/sqlite/install.sh` | Remove `cp …/jwt/*.pem` line | +| `contrib/dev-tools/container/e2e/mysql/install.sh` | Remove `cp …/jwt/*.pem` line | +| `compose.yaml` | Remove or comment out `AUTH__PRIVATE_KEY_PATH` / `AUTH__PUBLIC_KEY_PATH` env vars | +| `src/web/api/server/v1/contexts/user/mod.rs` | Update module-level doc example | + +#### Phase 6 — `generate-auth-keypair` CLI + Container Auto-Generation + +##### Motivation + +Phases 3 and 5 require deployers who want **persistent sessions** +to generate an RSA key pair externally (e.g., via `openssl`). +This creates an operational dependency on a tool that may not be +present in minimal container images (the runtime image is +distroless). Rather than adding `openssl` to the container, the +key generation capability is built into the project itself — the +`rsa` crate is already a direct dependency. + +The goal is zero-friction persistent sessions in the container: +on first boot, if no keys exist on the `/etc/torrust/index` +volume, the entry script generates them automatically. Subsequent +restarts reuse the same keys, so sessions survive. Hosts who want +their own keys either pre-populate the volume before the first +start, or overwrite the generated keys and restart. + +##### CLI binary — `torrust-generate-auth-keypair` + +A new binary `torrust-generate-auth-keypair` +(`src/bin/generate_auth_keypair.rs`) generates an RSA-2048 key +pair and writes both PEM blocks to **stdout**. Design constraints: + +- **Stdout must be piped.** The tool refuses to run if stdout is + a terminal (`std::io::stdout().is_terminal()`), printing a + usage hint to stderr and exiting with code 1. This prevents + accidental display of key material on screen. +- **Private key on stdout first, then the public key**, each in + standard PEM (Base64-encoded PKCS#8 / SPKI) format. The two + blocks are self-delimiting via their `-----BEGIN …-----` / + `-----END …-----` markers. +- **Diagnostic message on stderr** confirming the key was + generated (type, bit size). +- Uses `clap` (already a dependency) for `--help` and future + extensibility (e.g., `--bits`, `--out-dir`). +- Reuses the same `rsa` + `pkcs8` code path as the ephemeral + generator in `src/jwt.rs`. + +##### Container integration + +The container entry script (`share/container/entry_script_sh`) +auto-generates persistent keys on first boot: + +```sh +# Generate auth keys if not already present on the volume. +private_key="/etc/torrust/index/private.pem" +public_key="/etc/torrust/index/public.pem" + +if [ ! -f "$private_key" ] || [ ! -f "$public_key" ]; then + torrust-generate-auth-keypair > /tmp/auth_keys.pem 2>/dev/null + sed -n '/BEGIN PRIVATE/,/END PRIVATE/p' /tmp/auth_keys.pem > "$private_key" + sed -n '/BEGIN PUBLIC/,/END PUBLIC/p' /tmp/auth_keys.pem > "$public_key" + rm -f /tmp/auth_keys.pem + chown torrust:torrust "$private_key" "$public_key" + chmod 0400 "$private_key" + chmod 0440 "$public_key" +fi +``` + +Because `/etc/torrust/index` is a declared `VOLUME`, the +generated keys persist across container restarts and image +upgrades. Sessions survive as long as the volume is retained. + +All container configuration files (`share/default/config/`) set: +```toml +[auth] +private_key_path = "/etc/torrust/index/private.pem" +public_key_path = "/etc/torrust/index/public.pem" +``` + +##### Containerfile changes + +The `torrust-generate-auth-keypair` binary is copied into `/usr/bin/` in +both the debug and release runtime images alongside +`torrust-index` and `health_check`: + +```dockerfile +# Extract and Test (debug) +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 + +# Extract and Test (release) +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/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair +``` + +##### Host-supplied keys (custom key workflow) + +Hosts who want to use their own RSA key pair have two options: + +1. **Pre-supply before first boot.** Mount or copy key files into + the `/etc/torrust/index` volume before starting the container. + The entry script's existence check (`[ ! -f … ]`) will skip + generation and the server will use the host's keys directly. + +2. **Overwrite after first boot.** Let the container auto-generate + keys on first boot since it "just works". Later, replace the + generated PEM files on the volume with the host's own keys and + restart the container. The server picks up the new keys; any + tokens signed with the old keys are invalidated (users + re-login once). + +##### Usage outside containers + +```sh +# Generate and split into two files: +cargo run --bin torrust-generate-auth-keypair \ + | tee >(sed -n '/BEGIN PRIVATE/,/END PRIVATE/p' > private.pem) \ + >(sed -n '/BEGIN PUBLIC/,/END PUBLIC/p' > public.pem) \ + > /dev/null +``` + +##### Files affected + +| File | Change | +|---|---| +| `src/bin/torrust-generate-auth-keypair.rs` | New binary | +| `Cargo.toml` | No change — auto-discovered by Cargo | +| `Containerfile` | Copy `torrust-generate-auth-keypair` into `/app/bin/` in both debug and release stages | +| `share/container/entry_script_sh` | Add key-generation block before `exec su-exec` | +| `share/default/config/index.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | +| `share/default/config/index.container.mysql.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | +| `share/default/config/index.public.e2e.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | +| `share/default/config/index.public.e2e.container.mysql.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | +| `share/default/config/index.private.e2e.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | + +##### No breaking changes + +This phase adds a new binary and updates the container entry +script. Bare-metal deployments without key paths configured +continue to use Phase 5's ephemeral in-memory keys. Container +deployments gain automatic persistent keys with no manual setup. + ### Configuration Migration Deployers upgrading across Phase 2 / Phase 3 must: -1. Generate an RSA key pair (e.g., - `openssl genrsa -out private.pem 2048` and +1. Generate an RSA key pair — either via + `cargo run --bin torrust-generate-auth-keypair | …` (see Phase 6) or + externally (`openssl genrsa -out private.pem 2048` and `openssl rsa -in private.pem -pubout -out public.pem`). 2. Update the config to reference the key paths (or set env vars). 3. Accept that existing sessions will be invalidated (users @@ -440,14 +679,40 @@ Deployers upgrading across Phase 2 / Phase 3 must: A migration guide will accompany the release that ships Phase 3. +With Phase 5, steps 1–2 become **optional** for bare-metal +deployments. Without explicit key configuration the server +auto-generates ephemeral keys and functions immediately — +sessions simply do not survive restarts. + +With Phase 6, **container deployments handle key generation +automatically.** The entry script generates keys to the +`/etc/torrust/index` volume on first boot; the container configs +already point to the generated paths. No manual key generation or +config editing is required. Sessions persist across restarts as +long as the volume is retained. + +Note: the **serialized default config** changes in Phase 5 — the +bare-metal `[auth]` section will no longer contain +`private_key_path` / `public_key_path` entries. Container configs +*do* include these paths (pointing to `/etc/torrust/index/`). +Deployers who generate their config from defaults should be aware +of this difference. Existing configs that explicitly set these +fields are unaffected. + ## Consequences - Existing user sessions **will be invalidated** when Phase 2 ships (claim format change) and again if key material changes in Phase 3. Users must re-login. -- Deployers must generate and manage an RSA key pair (Phase 3). - A development-mode auto-generated key reduces friction for - local setups. +- **Container deployments** auto-generate persistent keys on + first boot (Phase 6). Sessions survive restarts with no manual + setup. Hosts who want their own keys pre-populate the volume or + overwrite the generated keys and restart. +- **Bare-metal deployments** without key paths configured use + ephemeral in-memory keys (Phase 5) — sessions do not survive + restarts. Deployers who want persistent sessions generate a key + pair via `torrust-generate-auth-keypair` (Phase 6) or `openssl` and + configure the paths. - Token revocation via a `token_generation` counter is included (Phase 4 / Option E). Password changes, role changes, and bans increment the counter and invalidate outstanding tokens. @@ -456,6 +721,89 @@ A migration guide will accompany the release that ships Phase 3. - External services can verify tokens using only the public key, enabling zero-trust verification without secret sharing. +## Testing Strategy + +The repository **does not ship any pre-generated RSA key material**. +Tests exercise three key-provisioning modes: + +### Crate-level tests (`src/tests/jwt.rs`) + +The existing `jwt_service()` helper constructs a `JsonWebToken` +with **no key paths configured**, exercising the ephemeral +in-memory generation code path (Phase 5). All round-trip, claim, +and error-path tests work unchanged — they only need a valid +`JsonWebToken` instance, regardless of how the keys were +provisioned. + +### Isolated e2e tests (bare-metal path) + +Isolated e2e tests (the default `cargo test` mode) start an +in-process server with a `TempDir`-based ephemeral configuration. +No key paths are configured, so the server auto-generates keys in +memory. Authentication works for the lifetime of the test process. +No special setup is required. + +### Container e2e tests (persistent-key path) + +Container e2e tests (`compose.yaml`) exercise the production-like +flow where `torrust-generate-auth-keypair` runs in the entry script: + +1. The entry script detects no keys on the `/etc/torrust/index` + volume and runs `torrust-generate-auth-keypair` to create them. +2. The container configs point `auth.private_key_path` and + `auth.public_key_path` at the generated files. +3. The server starts with host-supplied (volume-persisted) keys. +4. E2e tests run the full auth round-trip: register, login, + authenticated requests. + +Because the keys live on a volume, restarting the container +reuses the same key pair — proving session persistence across +restarts (the production contract). + +### Host-supplied-key e2e test + +A dedicated e2e test verifies that externally generated keys are +accepted. The test has an external dependency on **`openssl`** +(must be on `$PATH`). + +**Test outline (`tests/e2e/web/api/v1/contexts/user/`)**: + +1. **Generate a fresh RSA key pair via `openssl`** into a + temporary directory (`tempfile::TempDir`): + ```sh + openssl genrsa -out "$tmpdir/private.pem" 2048 + openssl rsa -in "$tmpdir/private.pem" -pubout -out "$tmpdir/public.pem" + ``` + Executed with `std::process::Command`. The test is + `#[ignore]`-gated (or behind a feature flag / env var) so CI + runners without `openssl` can skip it gracefully. + +2. **Start a test environment** with config overrides pointing + `auth.private_key_path` and `auth.public_key_path` at the + generated files. + +3. **Perform a full auth round-trip:** + - Register a user. + - Log in and receive a session JWT. + - Call an authenticated endpoint using the token. + - Verify the response succeeds (the host-supplied key pair is + used for signing and verification). + +4. **Restart the server** (same key pair, same temp dir) and + confirm the previously issued JWT is **still valid** — proving + session persistence across restarts. + +5. **Cleanup** — the `TempDir` drops automatically, removing the + generated keys. + +This test proves: +- The repository contains no key material and the server boots + without shipped keys. +- `openssl`-generated keys are accepted by + `EncodingKey::from_rsa_pem` / `DecodingKey::from_rsa_pem`. +- Sessions persist across restarts when the deployer supplies + their own keys. + ## Remaining Issues - **Problem #11 (`BearerToken` extractor returns `Ok(None)`).** diff --git a/compose.yaml b/compose.yaml index e2b4ad504..223e6a948 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,8 +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__PRIVATE_KEY_PATH=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH:-/var/lib/torrust/index/jwt/private.pem} - - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH=${TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH:-/var/lib/torrust/index/jwt/public.pem} networks: - server_side ports: diff --git a/contrib/dev-tools/container/e2e/mysql/install.sh b/contrib/dev-tools/container/e2e/mysql/install.sh index e103d0265..5cbb5a09d 100755 --- a/contrib/dev-tools/container/e2e/mysql/install.sh +++ b/contrib/dev-tools/container/e2e/mysql/install.sh @@ -16,10 +16,6 @@ MYSQL_DATABASE=$TORRUST_INDEX_DATABASE echo "Creating MySQL database '$MYSQL_DATABASE' for for E2E testing ..." MYSQL_PWD=$MYSQL_PASSWORD mysql -h $MYSQL_HOST -u $MYSQL_USER -e "CREATE DATABASE IF NOT EXISTS $MYSQL_DATABASE;" -# Copy JWT PEM keys into the volume-mounted path -mkdir -p ./storage/index/lib/jwt -cp ./share/default/jwt/private.pem ./share/default/jwt/public.pem ./storage/index/lib/jwt/ - ## Tracker # Generate the Tracker sqlite database directory and file if it does not exist diff --git a/contrib/dev-tools/container/e2e/sqlite/install.sh b/contrib/dev-tools/container/e2e/sqlite/install.sh index e24866ed1..24bb5cd7b 100755 --- a/contrib/dev-tools/container/e2e/sqlite/install.sh +++ b/contrib/dev-tools/container/e2e/sqlite/install.sh @@ -12,10 +12,6 @@ if ! [ -f "./storage/index/lib/database/${TORRUST_INDEX_DATABASE}.db" ]; then sqlite3 "./storage/index/lib/database/${TORRUST_INDEX_DATABASE}.db" "VACUUM;" fi -# Copy JWT PEM keys into the volume-mounted path -mkdir -p ./storage/index/lib/jwt -cp ./share/default/jwt/private.pem ./share/default/jwt/public.pem ./storage/index/lib/jwt/ - ## Tracker # Generate the Tracker sqlite database directory and file if it does not exist 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 582c49256..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,8 +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__PRIVATE_KEY_PATH="/var/lib/torrust/index/jwt/private.pem" \ - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/var/lib/torrust/index/jwt/public.pem" \ 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 5a313f3e2..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,8 +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__PRIVATE_KEY_PATH="/var/lib/torrust/index/jwt/private.pem" \ - TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/var/lib/torrust/index/jwt/public.pem" \ 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/docs/containers.md b/docs/containers.md index e14243314..18715a664 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -149,10 +149,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__PRIVATE_KEY_PATH` - Override of the RSA private key PEM file path for JWT signing. If set, this value overrides any value set in the config. -- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH` - Override of the RSA public key PEM file path for JWT verification. If set, this value overrides any value set in the config. -- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PEM` - Override with an inline RSA private key PEM string instead of a file path. -- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PEM` - Override with an inline RSA public key PEM string instead of a file path. +- `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`). @@ -203,10 +203,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__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" \ --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/share/default/config/index.container.mysql.toml b/share/default/config/index.container.mysql.toml index 52864dcfb..7a9dcda90 100644 --- a/share/default/config/index.container.mysql.toml +++ b/share/default/config/index.container.mysql.toml @@ -15,8 +15,6 @@ threshold = "info" token = "MyAccessToken" [auth] -private_key_path = "/var/lib/torrust/index/jwt/private.pem" -public_key_path = "/var/lib/torrust/index/jwt/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 c2fe1417f..86f410c7c 100644 --- a/share/default/config/index.container.sqlite3.toml +++ b/share/default/config/index.container.sqlite3.toml @@ -15,8 +15,6 @@ threshold = "info" token = "MyAccessToken" [auth] -private_key_path = "/var/lib/torrust/index/jwt/private.pem" -public_key_path = "/var/lib/torrust/index/jwt/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 97b1bbbaa..647406797 100644 --- a/share/default/config/index.development.sqlite3.toml +++ b/share/default/config/index.development.sqlite3.toml @@ -17,8 +17,6 @@ threshold = "info" token = "MyAccessToken" [auth] -private_key_path = "./share/default/jwt/private.pem" -public_key_path = "./share/default/jwt/public.pem" # 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 d69dbf78c..90eb29450 100644 --- a/share/default/config/index.private.e2e.container.sqlite3.toml +++ b/share/default/config/index.private.e2e.container.sqlite3.toml @@ -19,8 +19,6 @@ token = "MyAccessToken" url = "http://tracker:7070" [auth] -private_key_path = "/var/lib/torrust/index/jwt/private.pem" -public_key_path = "/var/lib/torrust/index/jwt/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 cd412fb31..ecce9f334 100644 --- a/share/default/config/index.public.e2e.container.mysql.toml +++ b/share/default/config/index.public.e2e.container.mysql.toml @@ -17,8 +17,6 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -private_key_path = "/var/lib/torrust/index/jwt/private.pem" -public_key_path = "/var/lib/torrust/index/jwt/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 8cc4785ec..b94d27a55 100644 --- a/share/default/config/index.public.e2e.container.sqlite3.toml +++ b/share/default/config/index.public.e2e.container.sqlite3.toml @@ -17,8 +17,6 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] -private_key_path = "/var/lib/torrust/index/jwt/private.pem" -public_key_path = "/var/lib/torrust/index/jwt/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 3f3b83bd2..154fc2493 100644 --- a/share/default/config/tracker.private.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.private.e2e.container.sqlite3.toml @@ -8,8 +8,6 @@ threshold = "info" token = "MyAccessToken" [auth] -private_key_path = "/var/lib/torrust/index/jwt/private.pem" -public_key_path = "/var/lib/torrust/index/jwt/public.pem" [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 11a23e0af..88f70b31c 100644 --- a/share/default/config/tracker.public.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.public.e2e.container.sqlite3.toml @@ -8,8 +8,6 @@ threshold = "info" token = "MyAccessToken" [auth] -private_key_path = "/var/lib/torrust/index/jwt/private.pem" -public_key_path = "/var/lib/torrust/index/jwt/public.pem" [core] listed = false diff --git a/share/default/jwt/private.pem b/share/default/jwt/private.pem deleted file mode 100644 index f521bba45..000000000 --- a/share/default/jwt/private.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxwHRc62iUwASm -6wsBtgAZx5mllHK9D1LlezLfYYxvggdS3Uj9tVQTbM4kso8cztxSp1Za3lskiU7J -xZbbLk0skxkCUfyubfuqT7w/Vqjy30HrMaXDSPmSxTATaibUu/v809TAf2+8uPPy -0aBoaPf7H/ru0dhfhgRiK65doDA3810UBM+QO2sZ05JtC/N48tbdX3Fr/LlXHv/2 -eqMqTvhHaN8CMwhcwKJMaFmp8ePVxopzruhp1qq8KJ9agKUNV9MVhcQhy7wWmz74 -rRE+Jm/b9uQQ02czBLMEMgfKu4+Z9bNsQwu6KbdPHxGuK8pF4DBlIjPcZRhpJIZp -deAVTJppAgMBAAECggEABY5W9MqU78Vav97r7qdCNIwVJORRe9BdTnf93JaffaLK -WNA65ldDJAJMJUCBkaxznY/GdrupjKhQEqV+9CXr2p9Uckqew9MDQU0RvMcYK9NH -q7LXFBYVWv2X5Zt1UEP5+eqjJUs2cmczlNzxjyHs0mgq/0kG4uF9BJaJ8jo+F5mR -1dZ4spKnavSSrq5YtMiaskCqKq74RuCGbmq7QtGNH/gAZXC//IIBL9oywOlXJyyx -nK7bhNWFyf2Q0f3e7Y+Mfvz/dJhIY3FD6H+r+XsN/qEFoArDS22PB8IxuW0RLAl2 -JRzsQCrAzteoK54eBCErOYKy6i31C/UUKvNi1QK2KQKBgQDy++PkL6OoqlOkG8XE -5sU/HktMWRHFxiecZeJC0gnGZ5mo40T0rgxFNJ8k0nE5Sc12dyztGt2g7HOJ88D+ -v5X9Wd2dzPeXkhzVul3DPRg6kDY8ZVYpUa0hTwnuY5kAXe8eUhdIE+3Aula3gB6Q -fm++5qT1S4CuIIywFmgj5tap1QKBgQC7RgQ8CiHqGYWZjnFvefUr6mXii+8ElM18 -UOclulUSsR4VgtMntTQ+3kB5/6sl08l9ydYflpM8PCL0lvaFcgBQAtPlZetzmFhA -aGodLzpjUv+rmTH3z51erjFZX0m5pguu9ivYAhYZv4bN3gkFwsrQgs9r0fRyCteu -VJvqPuOERQKBgA3H9Yvqm8ikKGxFWvko8YT77d9dqeFitLptGOEbUoybMZ7fjPin -qnB+ZIxNFzjdk7alWbn07R8EaiUn2wlXymT9JNGfX2eMVPBWSp0ZKPehWEIiqTlc -tYoPFowbwADCUx6QH1vqLXDh4Ks1rAYb9bCJGlADQUAe/nu6OZvXqtMlAoGARY1X -fUT2G4+nAsTYdGKDH/BKLr1x4+2v83/ImUZ+2hZV6f9QlOrDoKXCpIzD76ScrM8N -a2XtAO4EvXpjzGPuocirEgOsUp4+CI2++1/S+5iTxBN9b1/4PnXLdjnhk8WLiUt8 -NRlxQ9bSJhtUloMl+BLdHlo3wzMrr19VGMaKkVECgYEAjsWmpSM8c1KL7m94bvow -EkSlvH69fOhFMQnjFmhmkjjXhBTMYrAUrh5Evdgkvyq61zVFVqegQjIkixqLPJyK -WGJbdPugw9cyL6EroM/ZNWxedw2A8GvXMCBswHUZfp1yMErJwlDDCdluGvpUQUlO -kqS6GexERq/c2NQgLqd68HM= ------END PRIVATE KEY----- diff --git a/share/default/jwt/public.pem b/share/default/jwt/public.pem deleted file mode 100644 index baaa0211d..000000000 --- a/share/default/jwt/public.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAscB0XOtolMAEpusLAbYA -GceZpZRyvQ9S5Xsy32GMb4IHUt1I/bVUE2zOJLKPHM7cUqdWWt5bJIlOycWW2y5N -LJMZAlH8rm37qk+8P1ao8t9B6zGlw0j5ksUwE2om1Lv7/NPUwH9vvLjz8tGgaGj3 -+x/67tHYX4YEYiuuXaAwN/NdFATPkDtrGdOSbQvzePLW3V9xa/y5Vx7/9nqjKk74 -R2jfAjMIXMCiTGhZqfHj1caKc67oadaqvCifWoClDVfTFYXEIcu8Fps++K0RPiZv -2/bkENNnMwSzBDIHyruPmfWzbEMLuim3Tx8RrivKReAwZSIz3GUYaSSGaXXgFUya -aQIDAQAB ------END PUBLIC KEY----- diff --git a/src/config/v2/auth.rs b/src/config/v2/auth.rs index 94b690b3e..ca2f0afd3 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -1,7 +1,6 @@ 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; @@ -9,10 +8,6 @@ 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; -/// Default paths for the development RSA key pair shipped with the repo. -const DEFAULT_PRIVATE_KEY_PATH: &str = "./share/default/jwt/private.pem"; -const DEFAULT_PUBLIC_KEY_PATH: &str = "./share/default/jwt/public.pem"; - /// Authentication options. /// /// ## JWT signing (ADR-T-007) @@ -31,8 +26,10 @@ const DEFAULT_PUBLIC_KEY_PATH: &str = "./share/default/jwt/public.pem"; /// 2. **File paths** — `private_key_path` / `public_key_path`. /// Point to PEM files on disk. /// -/// If neither is provided, the development key pair shipped at -/// `share/default/jwt/` is used with a loud warning. +/// 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 { /// Inline RSA private key in PEM format (overrides `private_key_path`). @@ -73,8 +70,8 @@ impl Default for Auth { Self { private_key_pem: None, public_key_pem: None, - private_key_path: Self::default_private_key_path(), - public_key_path: Self::default_public_key_path(), + 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(), @@ -83,14 +80,12 @@ impl Default for Auth { } impl Auth { - #[allow(clippy::unnecessary_wraps)] // serde default must match the field type - fn default_private_key_path() -> Option { - Some(DEFAULT_PRIVATE_KEY_PATH.to_owned()) + const fn default_private_key_path() -> Option { + None } - #[allow(clippy::unnecessary_wraps)] // serde default must match the field type - fn default_public_key_path() -> Option { - Some(DEFAULT_PUBLIC_KEY_PATH.to_owned()) + const fn default_public_key_path() -> Option { + None } const fn default_session_token_lifetime_secs() -> u64 { @@ -105,74 +100,60 @@ impl Auth { PasswordConstraints::default() } - /// Resolve the RSA private key PEM bytes. + /// Resolve the RSA private key PEM bytes, if configured. /// /// Resolution order: /// 1. Inline PEM (`private_key_pem`) /// 2. File path (`private_key_path`) - /// 3. Fallback to default dev key path (with warning) + /// + /// 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 no valid private key PEM can be resolved. + /// Panics if a configured path exists but cannot be read. #[must_use] - pub fn resolve_private_key_pem(&self) -> Vec { + pub fn resolve_private_key_pem(&self) -> Option> { if let Some(ref pem) = self.private_key_pem { - return pem.as_bytes().to_vec(); + return Some(pem.as_bytes().to_vec()); } if let Some(ref path) = self.private_key_path { if Path::new(path).exists() { - if path == DEFAULT_PRIVATE_KEY_PATH { - warn!( - "Using the DEVELOPMENT RSA private key at `{path}`. \ - This key is PUBLIC and must NOT be used in production! \ - Generate your own key pair: \ - `openssl genrsa -out private.pem 2048 && openssl rsa -in private.pem -pubout -out public.pem`" - ); - } - return std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA private key from `{path}`: {e}")); + return Some(std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA private key from `{path}`: {e}"))); } } - panic!( - "No RSA private key configured. Set `auth.private_key_path` or `auth.private_key_pem` in the configuration, \ - or generate a key pair: `openssl genrsa -out private.pem 2048`" - ); + None } - /// Resolve the RSA public key PEM bytes. + /// Resolve the RSA public key PEM bytes, if configured. /// /// Resolution order: /// 1. Inline PEM (`public_key_pem`) /// 2. File path (`public_key_path`) - /// 3. Fallback to default dev key path (with warning) + /// + /// 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 no valid public key PEM can be resolved. + /// Panics if a configured path exists but cannot be read. #[must_use] - pub fn resolve_public_key_pem(&self) -> Vec { + pub fn resolve_public_key_pem(&self) -> Option> { if let Some(ref pem) = self.public_key_pem { - return pem.as_bytes().to_vec(); + return Some(pem.as_bytes().to_vec()); } if let Some(ref path) = self.public_key_path { if Path::new(path).exists() { - if path == DEFAULT_PUBLIC_KEY_PATH { - warn!( - "Using the DEVELOPMENT RSA public key at `{path}`. \ - This key is PUBLIC and must NOT be used in production!" - ); - } - return std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA public key from `{path}`: {e}")); + return Some(std::fs::read(path).unwrap_or_else(|e| panic!("Failed to read RSA public key from `{path}`: {e}"))); } } - panic!( - "No RSA public key configured. Set `auth.public_key_path` or `auth.public_key_pem` in the configuration, \ - or generate a key pair: `openssl rsa -in private.pem -pubout -out public.pem`" - ); + None } } diff --git a/src/jwt.rs b/src/jwt.rs index 64be1a7d0..32941d105 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -5,7 +5,7 @@ //! //! See ADR-T-007 for the rationale behind centralising JWT handling. //! -//! # Architecture (ADR-T-007 Phases 1–4) +//! # Architecture (ADR-T-007 Phases 1–5) //! //! **Phase 1 — Structural cleanup.** Consolidated all `jsonwebtoken` //! usage into this single module with `Result`-based error propagation. @@ -26,12 +26,21 @@ //! (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. 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::errors::AuthError; @@ -112,18 +121,50 @@ pub struct JsonWebToken { } impl JsonWebToken { - /// Create a new `JsonWebToken` service, resolving the RSA key pair - /// from the configuration. + /// 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 the RSA key PEM cannot be resolved or is invalid. + /// 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 = settings.auth.resolve_private_key_pem(); - let public_pem = settings.auth.resolve_public_key_pem(); + 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) @@ -254,3 +295,34 @@ 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 91e5bb282..dd1fb5282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,8 +189,6 @@ //! bind_address = "0.0.0.0:3001" //! //! [auth] -//! private_key_path = "./share/default/jwt/private.pem" -//! public_key_path = "./share/default/jwt/public.pem" //! //! [auth.password_constraints] //! min_password_length = 6 diff --git a/src/tests/config/mod.rs b/src/tests/config/mod.rs index 91632afa9..daba68e99 100644 --- a/src/tests/config/mod.rs +++ b/src/tests/config/mod.rs @@ -97,8 +97,6 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - private_key_path = "./share/default/jwt/private.pem" - public_key_path = "./share/default/jwt/public.pem" "#, )?; @@ -130,8 +128,6 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a token = "MyAccessToken" [auth] - private_key_path = "./share/default/jwt/private.pem" - public_key_path = "./share/default/jwt/public.pem" "# .to_string(); diff --git a/src/tests/config/v2/auth.rs b/src/tests/config/v2/auth.rs index 0f59ddd14..7a23ee09d 100644 --- a/src/tests/config/v2/auth.rs +++ b/src/tests/config/v2/auth.rs @@ -4,7 +4,8 @@ //! //! - `resolve_private_key_pem_from_inline` — inline PEM takes priority. //! - `resolve_public_key_pem_from_inline` — inline PEM takes priority. -//! - `resolve_private_key_pem_panics_when_no_key` — panics if no key is available. +//! - `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; @@ -15,7 +16,7 @@ fn resolve_private_key_pem_from_inline() { private_key_path: None, ..Auth::default() }; - let pem = auth.resolve_private_key_pem(); + let pem = auth.resolve_private_key_pem().expect("should resolve inline PEM"); assert!(pem.starts_with(b"-----BEGIN PRIVATE KEY-----")); } @@ -26,17 +27,16 @@ fn resolve_public_key_pem_from_inline() { public_key_path: None, ..Auth::default() }; - let pem = auth.resolve_public_key_pem(); + let pem = auth.resolve_public_key_pem().expect("should resolve inline PEM"); assert!(pem.starts_with(b"-----BEGIN PUBLIC KEY-----")); } #[test] -#[should_panic(expected = "No RSA private key configured")] -fn resolve_private_key_pem_panics_when_no_key() { +fn resolve_private_key_pem_returns_none_when_no_key() { let auth = Auth { private_key_pem: None, private_key_path: None, ..Auth::default() }; - drop(auth.resolve_private_key_pem()); + assert!(auth.resolve_private_key_pem().is_none()); } diff --git a/src/tests/jwt.rs b/src/tests/jwt.rs index 4a48dc80b..23e917036 100644 --- a/src/tests/jwt.rs +++ b/src/tests/jwt.rs @@ -33,23 +33,10 @@ use crate::errors::AuthError; use crate::jwt::JsonWebToken; use crate::models::user::UserCompact; -/// Build a `JsonWebToken` service backed by the development RSA key -/// pair shipped at `share/default/jwt/`. -/// -/// Uses absolute paths derived from `CARGO_MANIFEST_DIR` so the tests -/// are immune to working-directory changes from parallel tests. +/// 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()); - - // Override the key paths with absolute paths so that tests do not - // depend on the current working directory. - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); - { - let mut settings = cfg.settings.write().await; - settings.auth.private_key_path = Some(format!("{manifest_dir}/share/default/jwt/private.pem")); - settings.auth.public_key_path = Some(format!("{manifest_dir}/share/default/jwt/public.pem")); - } - JsonWebToken::new(cfg).await } diff --git a/src/web/api/server/v1/contexts/settings/mod.rs b/src/web/api/server/v1/contexts/settings/mod.rs index 33ed9517d..c64e2cadd 100644 --- a/src/web/api/server/v1/contexts/settings/mod.rs +++ b/src/web/api/server/v1/contexts/settings/mod.rs @@ -54,10 +54,10 @@ //! "tsl": null //! }, //! "auth": { -//! "private_key_pem": "***-redacted-private-key-pem***", +//! "private_key_pem": null, //! "public_key_pem": null, -//! "private_key_path": "***-redacted***", -//! "public_key_path": "./share/default/jwt/public.pem", +//! "private_key_path": null, +//! "public_key_path": null, //! "session_token_lifetime_secs": 1209600, //! "email_verification_token_lifetime_secs": 315569260, //! "password_constraints": { diff --git a/src/web/api/server/v1/contexts/user/mod.rs b/src/web/api/server/v1/contexts/user/mod.rs index 220592be9..a69dba5b0 100644 --- a/src/web/api/server/v1/contexts/user/mod.rs +++ b/src/web/api/server/v1/contexts/user/mod.rs @@ -45,8 +45,9 @@ //! //! ```toml //! [auth] -//! private_key_path = "/path/to/private.pem" -//! public_key_path = "/path/to/public.pem" +//! # 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) diff --git a/tests/fixtures/default_configuration.toml b/tests/fixtures/default_configuration.toml index 31694434e..b34b54d38 100644 --- a/tests/fixtures/default_configuration.toml +++ b/tests/fixtures/default_configuration.toml @@ -44,8 +44,6 @@ url = "udp://localhost:6969" bind_address = "0.0.0.0:3001" [auth] -private_key_path = "./share/default/jwt/private.pem" -public_key_path = "./share/default/jwt/public.pem" [auth.password_constraints] max_password_length = 64 From 49504f620eeb12dd7ab05528fe35b2ae2b2ecf73 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Wed, 15 Apr 2026 20:52:05 +0200 Subject: [PATCH 07/10] feat: add generate-auth-keypair CLI and container auto-generation (ADR-T-007 Phase 6) Add the `torrust-generate-auth-keypair` binary that generates an RSA-2048 key pair and writes both PEM blocks to stdout. The tool refuses to run when stdout is a terminal, uses structured JSON tracing on stderr, and supports a `--debug` flag for verbose output. The container entry script now auto-generates persistent auth keys on first boot into `/etc/torrust/index/auth/` with restrictive permissions (0400 private, 0440 public). A `mktemp` + `trap` pattern ensures key material is never world-readable and the temp file is cleaned up on interruption. Changes: - New binary: src/bin/generate_auth_keypair.rs - Containerfile: copy new binary in both debug and release stages - entry_script_sh: key-generation block before `exec su-exec` - All container configs: set auth key paths to /etc/torrust/index/auth/ - ADR-007: update Phase 6 to reflect implementation - docs/containers.md: document auth/ directory and volume layout - README: note container auto-generation for new users --- CHANGELOG.md | 6 + Cargo.toml | 4 + Containerfile | 7 +- README.md | 3 + adr/007-jwt-system-refactor.md | 124 +++++++++++++----- docs/containers.md | 9 ++ share/container/entry_script_sh | 25 ++++ .../default/config/index.container.mysql.toml | 2 + .../config/index.container.sqlite3.toml | 2 + .../index.private.e2e.container.sqlite3.toml | 2 + .../index.public.e2e.container.mysql.toml | 2 + .../index.public.e2e.container.sqlite3.toml | 2 + src/bin/generate_auth_keypair.rs | 110 ++++++++++++++++ src/jwt.rs | 9 +- 14 files changed, 273 insertions(+), 34 deletions(-) create mode 100644 src/bin/generate_auth_keypair.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f7501c6..d5ed8c981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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). diff --git a/Cargo.toml b/Cargo.toml index b6b2a73fb..0e80dcf6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,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 2ff4d5329..8d1788e4c 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,9 @@ TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") \ 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 diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index 3b029bd56..7929df798 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -461,9 +461,11 @@ phased rollout that subsumes Options A and B. The `rsa` crate (already a transitive dependency via `jsonwebtoken`'s `rust_crypto` feature) must be added as a **direct** dependency in `Cargo.toml` along with `rand` (for -`OsRng`). PEM export requires the `pkcs8` + `pem` features on -`rsa` (for `EncodePrivateKey::to_pkcs8_pem`) and `spki` (for -`EncodePublicKey::to_public_key_pem`). +`OsRng`). PEM export uses `EncodePrivateKey::to_pkcs8_pem` +(from `pkcs8`) and `EncodePublicKey::to_public_key_pem` (from +`spki`). Both are pulled in transitively by the `pem` feature +on `rsa` — no extra feature flags are needed beyond +`rsa = { features = ["std", "pem"] }`. ##### Key generation details @@ -527,7 +529,7 @@ touches the following files (non-exhaustive): | `compose.yaml` | Remove or comment out `AUTH__PRIVATE_KEY_PATH` / `AUTH__PUBLIC_KEY_PATH` env vars | | `src/web/api/server/v1/contexts/user/mod.rs` | Update module-level doc example | -#### Phase 6 — `generate-auth-keypair` CLI + Container Auto-Generation +#### Phase 6 — `generate-auth-keypair` CLI + Container Auto-Generation ✅ Implemented ##### Motivation @@ -560,43 +562,87 @@ pair and writes both PEM blocks to **stdout**. Design constraints: standard PEM (Base64-encoded PKCS#8 / SPKI) format. The two blocks are self-delimiting via their `-----BEGIN …-----` / `-----END …-----` markers. -- **Diagnostic message on stderr** confirming the key was - generated (type, bit size). -- Uses `clap` (already a dependency) for `--help` and future - extensibility (e.g., `--bits`, `--out-dir`). +- **Diagnostic output on stderr** via `tracing` (already a + dependency). A `--debug` flag switches the subscriber from + the default `info` level to `debug`, giving deployers + detailed timing and key-fingerprint output without polluting + the PEM stream on stdout. +- Uses `clap` (already a dependency) for polished `--help` + output and future extensibility (e.g., `--bits`, `--out-dir`). - Reuses the same `rsa` + `pkcs8` code path as the ephemeral generator in `src/jwt.rs`. ##### Container integration The container entry script (`share/container/entry_script_sh`) -auto-generates persistent keys on first boot: +auto-generates persistent keys on first boot (runs **after** +`adduser`, so the `torrust` user already exists): ```sh # Generate auth keys if not already present on the volume. -private_key="/etc/torrust/index/private.pem" -public_key="/etc/torrust/index/public.pem" - -if [ ! -f "$private_key" ] || [ ! -f "$public_key" ]; then - torrust-generate-auth-keypair > /tmp/auth_keys.pem 2>/dev/null - sed -n '/BEGIN PRIVATE/,/END PRIVATE/p' /tmp/auth_keys.pem > "$private_key" - sed -n '/BEGIN PUBLIC/,/END PUBLIC/p' /tmp/auth_keys.pem > "$public_key" - rm -f /tmp/auth_keys.pem +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 ``` +Hardening notes: +- **`mkdir -p "$auth_dir"` with `chmod 0700`** creates the + `auth/` subdirectory with restrictive permissions before any + key material is written. +- **`mktemp` + `chmod 0600`** creates the temp file with a + unique name and restrictive permissions immediately, so key + material is never world-readable — even momentarily. +- **`[ ! -s … ]`** (not `[ ! -f … ]`) checks that the file + exists **and** is non-empty, protecting against a previous run + that was killed mid-write and left a 0-byte PEM file. +- **`trap … EXIT`** ensures the temp file is cleaned up even if + the script is interrupted between write and `rm`. `/tmp` in + the container is typically `tmpfs` (RAM-backed), so the key + material never touches persistent storage. +- **`sed` patterns match the exact PEM markers** (`BEGIN PRIVATE + KEY` / `BEGIN PUBLIC KEY`) produced by PKCS#8 / SPKI encoding, + rather than loose substrings. +- **stderr flows to the container log** — only stdout is + redirected to the temp file, so diagnostic `tracing` output + and any error messages from the binary are visible in + `docker logs`. If generation fails, the script exits + non-zero. +- **TOCTOU note:** if two containers race against the same + volume, both could pass the `[ ! -s … ]` check and + overwrite each other's keys. This is unlikely in practice + (single-container deployments are the norm), but can be + mitigated with `flock` if needed. + Because `/etc/torrust/index` is a declared `VOLUME`, the generated keys persist across container restarts and image upgrades. Sessions survive as long as the volume is retained. -All container configuration files (`share/default/config/`) set: +All **container** configuration files (those with `container` in +the name under `share/default/config/`) set: ```toml [auth] -private_key_path = "/etc/torrust/index/private.pem" -public_key_path = "/etc/torrust/index/public.pem" +private_key_path = "/etc/torrust/index/auth/private.pem" +public_key_path = "/etc/torrust/index/auth/public.pem" ``` ##### Containerfile changes @@ -606,25 +652,30 @@ both the debug and release runtime images alongside `torrust-index` and `health_check`: ```dockerfile -# Extract and Test (debug) +# Extract and Test (debug) — add to the existing cp -l line: 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 -# Extract and Test (release) +# Extract and Test (release) — add to the existing cp -l block: 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/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair ``` +Note: the debug stage currently copies only `torrust-index` +(no `health_check`). The new binary follows the same pattern. +The release stage already copies both `torrust-index` and +`health_check`, so the new binary is appended to that block. + ##### Host-supplied keys (custom key workflow) Hosts who want to use their own RSA key pair have two options: 1. **Pre-supply before first boot.** Mount or copy key files into the `/etc/torrust/index` volume before starting the container. - The entry script's existence check (`[ ! -f … ]`) will skip + The entry script's existence check (`[ ! -s … ]`) will skip generation and the server will use the host's keys directly. 2. **Overwrite after first boot.** Let the container auto-generate @@ -637,19 +688,26 @@ Hosts who want to use their own RSA key pair have two options: ##### Usage outside containers ```sh -# Generate and split into two files: -cargo run --bin torrust-generate-auth-keypair \ - | tee >(sed -n '/BEGIN PRIVATE/,/END PRIVATE/p' > private.pem) \ - >(sed -n '/BEGIN PUBLIC/,/END PUBLIC/p' > public.pem) \ - > /dev/null +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. + ##### Files affected | File | Change | |---|---| -| `src/bin/torrust-generate-auth-keypair.rs` | New binary | -| `Cargo.toml` | No change — auto-discovered by Cargo | +| `src/bin/generate_auth_keypair.rs` | New binary | +| `Cargo.toml` | Add `[[bin]]` section: `name = "torrust-generate-auth-keypair"`, `path = "src/bin/generate_auth_keypair.rs"` | | `Containerfile` | Copy `torrust-generate-auth-keypair` into `/app/bin/` in both debug and release stages | | `share/container/entry_script_sh` | Add key-generation block before `exec su-exec` | | `share/default/config/index.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | @@ -658,6 +716,10 @@ cargo run --bin torrust-generate-auth-keypair \ | `share/default/config/index.public.e2e.container.mysql.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | | `share/default/config/index.private.e2e.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | +Note: there is no `index.private.e2e.container.mysql.toml` at +present. If one is added in the future, it will also need the +`[auth]` key paths. + ##### No breaking changes This phase adds a new binary and updates the container entry @@ -694,7 +756,7 @@ long as the volume is retained. Note: the **serialized default config** changes in Phase 5 — the bare-metal `[auth]` section will no longer contain `private_key_path` / `public_key_path` entries. Container configs -*do* include these paths (pointing to `/etc/torrust/index/`). +*do* include these paths (pointing to `/etc/torrust/index/auth/`). Deployers who generate their config from defaults should be aware of this difference. Existing configs that explicitly set these fields are unaffected. diff --git a/docs/containers.md b/docs/containers.md index 18715a664..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 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 7a9dcda90..fd8ae638a 100644 --- a/share/default/config/index.container.mysql.toml +++ b/share/default/config/index.container.mysql.toml @@ -15,6 +15,8 @@ threshold = "info" token = "MyAccessToken" [auth] +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 86f410c7c..d5c1902bb 100644 --- a/share/default/config/index.container.sqlite3.toml +++ b/share/default/config/index.container.sqlite3.toml @@ -15,6 +15,8 @@ threshold = "info" token = "MyAccessToken" [auth] +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.private.e2e.container.sqlite3.toml b/share/default/config/index.private.e2e.container.sqlite3.toml index 90eb29450..6137ed6dc 100644 --- a/share/default/config/index.private.e2e.container.sqlite3.toml +++ b/share/default/config/index.private.e2e.container.sqlite3.toml @@ -19,6 +19,8 @@ token = "MyAccessToken" url = "http://tracker:7070" [auth] +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 ecce9f334..d36248512 100644 --- a/share/default/config/index.public.e2e.container.mysql.toml +++ b/share/default/config/index.public.e2e.container.mysql.toml @@ -17,6 +17,8 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] +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 b94d27a55..62c726dad 100644 --- a/share/default/config/index.public.e2e.container.sqlite3.toml +++ b/share/default/config/index.public.e2e.container.sqlite3.toml @@ -17,6 +17,8 @@ token = "MyAccessToken" url = "udp://tracker:6969" [auth] +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/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/jwt.rs b/src/jwt.rs index 32941d105..9ec1da60f 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -5,7 +5,7 @@ //! //! See ADR-T-007 for the rationale behind centralising JWT handling. //! -//! # Architecture (ADR-T-007 Phases 1–5) +//! # Architecture (ADR-T-007 Phases 1–6) //! //! **Phase 1 — Structural cleanup.** Consolidated all `jsonwebtoken` //! usage into this single module with `Result`-based error propagation. @@ -32,6 +32,13 @@ //! 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. use std::sync::Arc; From e23cd3f660ebe4bad8a9c1ebffd310f2ef9e9f56 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 16 Apr 2026 09:09:20 +0200 Subject: [PATCH 08/10] feat(jwt): atomic token revocation and hardened session validation (ADR-T-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce atomic database methods that combine state changes with `token_generation` bumps in a single transaction, closing the race window where a password change or ban could commit but the generation increment could fail — leaving revoked credentials with valid tokens: - `change_user_password_and_revoke_tokens` (transaction) - `ban_user_and_revoke_tokens` (transaction) - `grant_admin_role_and_revoke_tokens` (single UPDATE) Harden token-generation checking from `<` to `!=` (exact match) at all three validation sites so tokens are also rejected when the database generation *decreases* (e.g. restore from backup). Add defence-in-depth `is_user_banned` checks after generation validation — if `token_generation` somehow matches despite an active ban, the ban table catches it. Other fixes included in this changeset: - `BearerToken::value()` → `as_str()`: return `&str` instead of cloning. - `parse_token`: require the space in `"Bearer "` prefix (`strip_prefix("Bearer")` → `strip_prefix("Bearer ")`). - `get_token_generation`: distinguish `RowNotFound` from other errors; replace `unwrap_or(0)` with proper error propagation. - `Settings::remove_secrets_from_settings`: conditionally redact `Option` key fields instead of unconditionally overwriting `None` with `Some("***")`. Also redact public key pem/path. - Warn when configured key paths don't exist (falling back to ephemeral keys). - Replace hardcoded JWT tokens in doc examples with ``. - Fix doc typo: "JWT is not invalid" → "JWT is invalid". Condense ADR-T-007 and add Phase 7 (consolidate session validation) design. --- adr/007-jwt-system-refactor.md | 1276 ++++++----------- src/config/v2/auth.rs | 3 + src/config/v2/mod.rs | 14 +- src/databases/database.rs | 20 + src/databases/mysql.rs | 128 +- src/databases/sqlite.rs | 130 +- src/services/authentication.rs | 21 +- src/services/user.rs | 40 +- src/web/api/server/v1/auth.rs | 19 +- .../api/server/v1/contexts/category/mod.rs | 4 +- src/web/api/server/v1/contexts/proxy/mod.rs | 2 +- .../api/server/v1/contexts/settings/mod.rs | 2 +- src/web/api/server/v1/contexts/tag/mod.rs | 4 +- src/web/api/server/v1/contexts/torrent/mod.rs | 12 +- .../api/server/v1/contexts/user/handlers.rs | 20 +- src/web/api/server/v1/contexts/user/mod.rs | 12 +- .../api/server/v1/extractors/bearer_token.rs | 4 +- tests/e2e/environment.rs | 7 +- 18 files changed, 841 insertions(+), 877 deletions(-) diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index 7929df798..88afca483 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -1,691 +1,269 @@ # ADR-T-007: Refactor the JWT System -**Status:** Implemented +**Status:** Phases 1–6 implemented · Phase 7 pending **Date:** 2026-04-14 +**Updated:** 2026-04-16 ## Context -The JWT (JSON Web Token) authentication system has grown organically -and currently exhibits several structural and security problems. -This ADR catalogues the issues and presents options for a -comprehensive refactor. - -### Current Architecture - -JWT handling is spread across four locations with two distinct -claim types: +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` | Thin wrapper delegating to `JsonWebToken` | `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` | -Both token types share the same HMAC-HS256 signing secret -(`auth.user_claim_token_pepper`). - -### Identified Problems - -1. **Single shared secret for all token purposes.** - Session JWTs and email-verification JWTs share the same - HMAC secret (`user_claim_token_pepper`). A leaked session - token secret also compromises email-verification tokens, - and vice-versa. There is no audience/purpose separation at - the key level. - -2. **HMAC-HS256 with a text "pepper" as the key.** - The signing key is an arbitrary human-readable string - (default: `"MaxVerstappenWC2021"`), used directly via - `EncodingKey::from_secret(key.as_bytes())`. There is no - minimum entropy requirement, no key derivation, and no - support for asymmetric algorithms. HMAC-HS256 with a - low-entropy secret is vulnerable to offline brute-force - attacks on captured tokens. - -3. **No `iss`, `aud`, or `sub` claims on session tokens.** - `UserClaims` contains only `{ user: UserCompact, exp: u64 }`. - It has no `iss` (issuer), `aud` (audience), `sub` (subject), - or `iat` (issued-at) fields. RFC 7519 recommends these - registered claims for interoperability and security. By - contrast, `VerifyClaims` in the mailer *does* set `iss` and - `sub`, but not `aud`. - -4. **User data embedded verbatim in the JWT payload.** - `UserClaims` embeds the full `UserCompact` struct - (`user_id`, `username`, `administrator`) in the payload. - This means the `administrator` flag is trusted from the token - rather than re-checked from the database at each request. If - a user's role changes, existing tokens carry stale privileges - until they expire. Role escalation tokens remain valid for - the full two-week window. - -5. **Hard-coded expiration durations.** - Session tokens expire in `1_209_600` seconds (2 weeks) with a - `// todo` comment acknowledging this should be configurable. - Email-verification tokens expire in `315_569_260` seconds - (~10 years). The renewal threshold is a hard-coded - `ONE_WEEK_IN_SECONDS`. None of these are configurable. - -6. **Redundant / manual expiration checking.** - `JsonWebToken::verify` passes `Validation::new(Algorithm::HS256)` - to `jsonwebtoken::decode`, which already validates `exp` by - default, yet the code performs an *additional* manual - `if token_data.claims.exp < clock::now()` check. These two - checks may disagree in edge cases (clock skew handling - differs). - -7. **`.unwrap()` when signing tokens.** - `mailer::get_verification_url` calls `encode(...).unwrap()`. - While `JsonWebToken::sign` uses `.expect()` with a message, - both paths will panic at runtime if encoding fails, rather - than returning an error through the service layer. - -8. **`parse_token` panics on malformed headers.** - `parse_token` calls `.expect()` on `to_str()` and blindly - indexes into `split[1]`. A malformed `Authorization` header - will panic the request handler. - -9. **No token revocation mechanism.** - There is no blacklist, version counter, or server-side session - store. Once a JWT is signed, it is valid until `exp`. Password - changes, role changes, and bans do not invalidate outstanding - tokens. - -10. **Scattered `jsonwebtoken` usage — no single module.** - The `jsonwebtoken` crate is imported directly in three files - (`services/authentication.rs`, `mailer.rs`, `services/user.rs`). - There is no centralised JWT module that owns signing, verification, - key management, and algorithm configuration. Changing the algorithm - or key format requires touching multiple files. - -11. **`BearerToken` extractor returns `Ok(None)` on missing header.** - The Axum extractor never rejects a request for a missing - `Authorization` header — it returns `Ok(Extract(None))` and - defers the check downstream. This means every handler that - requires authentication must remember to check for `None` - and return `TokenNotFound` itself. - -12. **`ClaimTokenPepper` naming is misleading.** - In cryptography a "pepper" is a secret added to a password - hash. Here it is used as an HMAC signing key, which is a - fundamentally different concept. The name - `user_claim_token_pepper` / `ClaimTokenPepper` obscures the - actual role of the value. - -## Options - -### Option A — Incremental Cleanup (minimal scope) - -Fix the most acute issues without changing the token format or -breaking API compatibility. - -**Changes:** - -- Extract a `jwt` module (`src/jwt.rs` or `src/jwt/mod.rs`) that - centralises all `jsonwebtoken` usage: key loading, `sign`, - `verify`, algorithm config. -- Move `VerifyClaims` into the new module alongside `UserClaims`. -- Make expiration durations configurable in `Auth` config - (`session_token_lifetime_seconds`, - `email_verification_token_lifetime_seconds`). -- Remove the redundant manual `exp` check — rely on the library's - built-in validation. -- Replace `.unwrap()` / `.expect()` with `Result` propagation. -- Fix `parse_token` to return `Result` instead of panicking. -- Rename `ClaimTokenPepper` → `JwtSigningSecret` (or similar). -- Add `iss` and `sub` claims to `UserClaims`. - -**Pros:** -- Small diff, low risk, no breaking API change. -- All existing tokens remain valid (backward-compatible). - -**Cons:** -- Does not address the stale-role-in-token problem (#4). -- Does not address single-secret-for-all-purposes (#1). -- Does not add revocation (#9). -- HMAC-HS256 with a low-entropy secret remains (#2). - ---- - -### Option B — Proper Claim Design + Per-Purpose Keys - -Redesign the JWT claims to follow RFC 7519 best practices and -introduce separate signing keys per token purpose. - -**Changes (includes all of Option A, plus):** - -- Split the config secret into two independent keys: - `auth.session_signing_key` and `auth.email_verification_signing_key`. -- Redesign `UserClaims` to standard registered claims: - ```rust - struct SessionClaims { - sub: UserId, // subject = user ID - iss: String, // "torrust-index" - aud: String, // "session" - iat: u64, - exp: u64, - role: Role, // admin / user - username: String, // convenience, non-authoritative - } - ``` -- Redesign `VerifyClaims` similarly with `aud: "email-verification"`. -- Re-validate the `role` / `administrator` flag from the database - on every authenticated request (or cache with a short TTL) so - the token role is advisory only. -- Enforce a minimum secret length at config validation time. - -**Pros:** -- Purpose-separated keys: compromising one does not affect the other. -- Stale roles no longer grant elevated privileges. -- Standards-compliant claims improve interoperability. - -**Cons:** -- **Breaking change** — existing session tokens become invalid on - deploy (users must re-login). -- Config migration required (new key names). -- Re-checking the user role on every request adds a database - round-trip (can be mitigated with a short-lived cache). - ---- - -### Option C — Asymmetric Signing (RS256 / EdDSA) - -Move from symmetric HMAC to an asymmetric algorithm. - -**Changes (includes all of Option B, plus):** - -- Replace `HS256` with `RS256` or `EdDSA`. -- Store a private key (PEM / PKCS#8) for signing and a - corresponding public key for verification. -- Config provides a `auth.private_key_path` and - `auth.public_key_path` (or inline PEM via env var). -- Only the signing service needs the private key; the verification - layer (and potentially external services) only need the public - key. -- Supports future use-cases like external microservices verifying - tokens without sharing a secret, or JWKS endpoint publishing. - -**Pros:** -- Strongest security posture — no shared secret. -- Enables zero-trust verification by third-party services. -- Aligns with modern OAuth 2.0 / OIDC practices. -- Supports key rotation via JWKS-style `kid` header. - -**Cons:** -- Significantly higher complexity: key generation, storage, rotation. -- Larger tokens (RSA signatures are ~256 bytes vs. 32 for HMAC). - EdDSA mitigates this (~64 bytes). -- Operational burden: deployers must generate and manage key pairs. -- Breaking change — same token-invalidation concern as Option B. -- `simple_asn1` / `time` pin issues (see ADR-T-005) may constrain - which `jsonwebtoken` features can be enabled. - ---- - -### Option D — Move to Opaque Session Tokens + Server-Side Store - -Replace JWTs entirely with opaque session tokens backed by a -server-side session store. - -**Changes:** - -- Generate a cryptographically random opaque token on login - (e.g., 256-bit via `rand`). -- Store a session record (token hash, user ID, expiry, role) in a - new `torrust_sessions` database table (or Redis / in-memory cache). -- On each request, look up the token hash in the store, reject if - absent or expired. -- Email-verification tokens can remain as purpose-specific JWTs - (short-lived, no session semantics) or also become opaque + - stored. -- Remove the `jsonwebtoken` dependency entirely (or keep it only - for email-verification links). - -**Pros:** -- Instant revocation — delete the row, token is dead. -- No stale-role problem — role is always read from the store/DB. -- No secret-key management for session tokens. -- Eliminates all JWT-specific bugs (claim design, algorithm - confusion, etc.). - -**Cons:** -- Every authenticated request requires a store lookup (DB or cache). -- New infrastructure dependency if using Redis; new migration if - using the DB. -- Loses the statelessness benefit of JWTs. -- Larger scope — session management, garbage collection of expired - rows, etc. -- Breaking change for any client that currently introspects the - JWT payload. - ---- - -### Option E — Hybrid (JWT + Server-Side Revocation List) - -Keep JWTs for their stateless benefits but add a lightweight -server-side mechanism for revocation. - -**Changes (includes all of Option B, plus):** - -- Add a `token_generation` (or `jwt_epoch`) integer column to the - `torrust_users` table. Increment it on password change, role - change, or ban. -- Include `gen: u64` (the user's `token_generation` at sign time) - in the JWT claims. -- On verification, compare the token's `gen` to the current - database value; reject if stale. -- Optionally: maintain a small in-memory - `HashMap` cache with a TTL of a few - seconds, so the DB is not hit on every request. - -**Pros:** -- Retains stateless JWT benefits for the common (non-revoked) case. -- Revocation is near-instant (one DB update per user). -- Smaller scope than full session-store migration. -- Compatible with either symmetric or asymmetric signing. - -**Cons:** -- Still requires a DB/cache lookup per request (though cacheable). -- More complex than pure JWT or pure server-side sessions. -- Breaking change alongside Option B's claim redesign. +### 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 — Asymmetric Signing with RS256**, implemented as a -phased rollout that subsumes Options A and B. - -### Why Option C - -1. **Dependency already supports it.** `jsonwebtoken 10.3.0` with - `rust_crypto` already enables `rsa`, `pem`, `sha2`, and - `use_pem`. No new crates, no feature-flag changes, no pin - concerns (the `simple_asn1`/`time` pin issues from ADR-T-003 - were resolved in ADR-T-005). - -2. **Strongest security posture.** Asymmetric signing eliminates - the shared-secret problem entirely. Only the signing service - holds the private key; verification requires only the public - key. This is a strict improvement over HS256 with a low-entropy - pepper. - -3. **Future-proof.** Enables external microservices (e.g., a - tracker, a frontend SSR server) to verify tokens without - sharing a secret. A JWKS endpoint or `kid` header can be added - later for key rotation without protocol changes. - -4. **Subsumes Options A and B.** The phased plan below delivers - all of Option A's cleanup and Option B's claim redesign as - prerequisite steps before switching the algorithm. - -### Why RS256 (not EdDSA) - -- RS256 (`RSASSA-PKCS1-v1_5 + SHA-256`) is the most widely - supported JWT algorithm across languages, libraries, and cloud - services. Every JWT implementation is required to support it - (RFC 7518 §3.1). -- EdDSA (Ed25519) produces smaller signatures (~64 bytes vs. - ~256 bytes for RS256), but the size difference is negligible - for authentication tokens transmitted once per request in an - HTTP header. -- RS256 key generation and management are well-understood - operationally (`openssl genrsa`). -- If EdDSA is desired in the future, the centralised `jwt` module - makes the algorithm a single-point change. - -### Why not Options D or E - -- **Option D (opaque tokens):** Adds a mandatory server-side store - (database table or Redis) on every request path. The project - does not currently need instant revocation badly enough to - justify the infrastructure cost and loss of statelessness. -- **Option E (hybrid revocation):** The `token_generation` column - approach was originally deferred but has since been implemented - in Phase 4. See the Phase 4 section below. - -### Implementation Phases - -#### Phase 1 — Structural Cleanup (Option A scope) ✅ Implemented - -- ✅ Extract a `src/jwt.rs` module that centralises all - `jsonwebtoken` usage: key loading, `sign`, `verify`, algorithm - configuration. -- ✅ Move `UserClaims` and `VerifyClaims` into the new module. -- ✅ Replace `.unwrap()` / `.expect()` with `Result` propagation. -- ✅ Fix `parse_token` to return `Result` instead of panicking. -- ✅ Remove the redundant manual `exp` check (the library's - `Validation` already handles it). -- ✅ Rename `ClaimTokenPepper` → `JwtSigningSecret` - throughout config and code. -- ✅ Make expiration durations configurable: - `session_token_lifetime_secs`, - `email_verification_token_lifetime_secs`. - -#### Phase 2 — Claim Redesign + Per-Purpose Keys (Option B scope) ✅ Implemented - -- ✅ Redesign `UserClaims` → `SessionClaims`: - ```rust - struct SessionClaims { - sub: UserId, // subject = user ID - iss: String, // "torrust-index" - aud: String, // "session" - iat: u64, - exp: u64, - role: Role, // admin | user (advisory only) - username: String, // convenience, non-authoritative - } - ``` -- ✅ Redesign `VerifyClaims` with `aud: "email-verification"`. -- ✅ Split config into two independent keys: - `auth.session_signing_key` and - `auth.email_verification_signing_key`. -- ✅ Re-validate the user's role from the database on every - authenticated request (the authorization service already does - this via `get_role`) so the token role is advisory only. -- ✅ Enforce a minimum secret length (32 bytes) at config - validation time. *(With Phase 3's move to RS256, this is now - enforced implicitly: `EncodingKey::from_rsa_pem` / - `DecodingKey::from_rsa_pem` reject invalid PEM at startup.)* -- **Breaking change:** existing HS256 tokens are invalidated; - users must re-login. - -#### Phase 3 — RS256 Asymmetric Signing (Option C scope) ✅ Implemented - -- ✅ Replace `HS256` with `RS256` (`Algorithm::RS256`). -- ✅ Config provides: - - `auth.private_key_path` (PEM / PKCS#8) for signing. - - `auth.public_key_path` for verification. - - Alternatively, inline PEM via environment variable - (`auth.private_key_pem`, `auth.public_key_pem`). -- ~~Development key pair shipped at `share/default/jwt/` with loud - startup warning when the default dev keys are used.~~ - *(Implemented, then superseded by Phase 5 — ephemeral - auto-generated keys replace the shipped dev keys.)* -- ✅ Use `EncodingKey::from_rsa_pem` / `DecodingKey::from_rsa_pem`. -- ✅ Only the signing service loads the private key; the - verification path uses the public key. -- ✅ A `kid` (Key ID) is included in every JWT header (SHA-256 - fingerprint of the public key) to support future key rotation. -- **Breaking change:** existing HS256 tokens and config - (`session_signing_key`, `email_verification_signing_key`) are - no longer supported. Deployers must generate an RSA key pair - and update their configuration. - -#### Phase 4 — Optional Revocation (Option E scope) ✅ Implemented - -- ✅ Add a `token_generation` column (default `0`) to - `torrust_users`. -- ✅ Include `gen` in `SessionClaims`; reject tokens whose `gen` - is older than the current database value. -- ✅ Increment `token_generation` on password change, role change - (admin grant), and ban. -- ✅ Validation performed in the `Authentication` web layer - (`get_user_id_from_bearer_token`), the `verify_token_handler`, - and the `renew_token` service method. - *Defence in depth:* the generation check is intentionally - repeated at each entry point rather than consolidated into a - single layer, so that no call path can accidentally bypass - revocation. -- **Breaking change:** existing tokens without a `gen` claim will - fail deserialization and be rejected (users re-login once). - -#### Phase 5 — Ephemeral Auto-Generated Keys (default) ✅ Implemented - -##### Behaviour - -- Remove the shipped development key pair from `share/default/jwt/`. -- On startup, if no key paths or PEM values are configured, - **auto-generate an RSA-2048 key pair in memory** via the `rsa` - crate's `RsaPrivateKey::new(&mut OsRng, 2048)`. -- The generated keys are held only in process memory and are - **never written to disk**. On shutdown (or crash) the keys are - lost; all outstanding tokens become unverifiable and users must - re-login. -- Log a clear informational message at startup: - `"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."` -- No development-mode keys exist in the repository. There is no - distinction between "dev" and "prod" key material — only - between ephemeral (default) and host-supplied (persistent). -- For **persistent sessions across restarts**, the deployer - generates their own RSA key pair and configures the paths or - environment variables as described in Phase 3. -- **No breaking change for existing Phase 3 deployers** who - already supply their own key pair — their configuration - continues to work as before. Only the *default* behaviour - changes (from shipped dev keys to ephemeral keys). - -##### New dependencies - -The `rsa` crate (already a transitive dependency via -`jsonwebtoken`'s `rust_crypto` feature) must be added as a -**direct** dependency in `Cargo.toml` along with `rand` (for -`OsRng`). PEM export uses `EncodePrivateKey::to_pkcs8_pem` -(from `pkcs8`) and `EncodePublicKey::to_public_key_pem` (from -`spki`). Both are pulled in transitively by the `pem` feature -on `rsa` — no extra feature flags are needed beyond -`rsa = { features = ["std", "pem"] }`. - -##### Key generation details - -`RsaPrivateKey::new()` is CPU-intensive (~100-300 ms for 2048 -bits). Because `JsonWebToken::new()` is `async`, the generation -must be wrapped in `tokio::task::spawn_blocking` to avoid stalling -the async executor. - -After generation, the private and public keys are exported to -in-memory PEM byte vectors via: +**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 -use rsa::pkcs8::EncodePrivateKey; -use rsa::pkcs8::LineEnding; -use spki::EncodePublicKey; - -let private_pem = private_key - .to_pkcs8_pem(LineEnding::LF) - .expect("PEM export"); -let public_pem = private_key - .to_public_key() - .to_public_key_pem(LineEnding::LF) - .expect("PEM export"); +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) +} ``` -These PEM bytes are then passed to `EncodingKey::from_rsa_pem` / -`DecodingKey::from_rsa_pem` exactly as the host-supplied path does -today. - -##### Interface changes - -- **`Auth::resolve_private_key_pem()` / `resolve_public_key_pem()`** - currently **panic** when no key is found. These methods must - change their return type to `Option>` so the caller - (`JsonWebToken::new`) can distinguish "no key configured" from - "key configured but invalid". -- **`Auth::default()`** must set `private_key_path` and - `public_key_path` to `None` (not the former - `./share/default/jwt/…` paths). The `DEFAULT_PRIVATE_KEY_PATH` - and `DEFAULT_PUBLIC_KEY_PATH` constants are removed. -- **`JsonWebToken::new()`** gains a new branch: when both - `resolve_*` methods return `None`, it generates an ephemeral - key pair (via `spawn_blocking`) and logs the informational - message. - -##### Files affected by dev-key removal - -Removing `share/default/jwt/` and the default-path constants -touches the following files (non-exhaustive): -| File | Change | -|---|---| -| `share/default/jwt/private.pem`, `public.pem` | Delete | -| `src/config/v2/auth.rs` | Remove `DEFAULT_*_KEY_PATH` constants; change defaults to `None`; return `Option` from `resolve_*` | -| `src/jwt.rs` | Add ephemeral-generation branch in `JsonWebToken::new()` | -| `src/lib.rs` | Update doc-comment example config (remove key paths from default) | -| `src/tests/jwt.rs` | `jwt_service()` helper uses ephemeral path (no path overrides) | -| `tests/fixtures/default_configuration.toml` | Remove `private_key_path` / `public_key_path` lines | -| `.env.local` | Remove `AUTH__PRIVATE_KEY_PATH` / `AUTH__PUBLIC_KEY_PATH` overrides | -| `share/default/config/index.development.sqlite3.toml` | Remove key-path lines | -| `contrib/dev-tools/container/e2e/sqlite/install.sh` | Remove `cp …/jwt/*.pem` line | -| `contrib/dev-tools/container/e2e/mysql/install.sh` | Remove `cp …/jwt/*.pem` line | -| `compose.yaml` | Remove or comment out `AUTH__PRIVATE_KEY_PATH` / `AUTH__PUBLIC_KEY_PATH` env vars | -| `src/web/api/server/v1/contexts/user/mod.rs` | Update module-level doc example | - -#### Phase 6 — `generate-auth-keypair` CLI + Container Auto-Generation ✅ Implemented - -##### Motivation - -Phases 3 and 5 require deployers who want **persistent sessions** -to generate an RSA key pair externally (e.g., via `openssl`). -This creates an operational dependency on a tool that may not be -present in minimal container images (the runtime image is -distroless). Rather than adding `openssl` to the container, the -key generation capability is built into the project itself — the -`rsa` crate is already a direct dependency. - -The goal is zero-friction persistent sessions in the container: -on first boot, if no keys exist on the `/etc/torrust/index` -volume, the entry script generates them automatically. Subsequent -restarts reuse the same keys, so sessions survive. Hosts who want -their own keys either pre-populate the volume before the first -start, or overwrite the generated keys and restart. - -##### CLI binary — `torrust-generate-auth-keypair` - -A new binary `torrust-generate-auth-keypair` -(`src/bin/generate_auth_keypair.rs`) generates an RSA-2048 key -pair and writes both PEM blocks to **stdout**. Design constraints: - -- **Stdout must be piped.** The tool refuses to run if stdout is - a terminal (`std::io::stdout().is_terminal()`), printing a - usage hint to stderr and exiting with code 1. This prevents - accidental display of key material on screen. -- **Private key on stdout first, then the public key**, each in - standard PEM (Base64-encoded PKCS#8 / SPKI) format. The two - blocks are self-delimiting via their `-----BEGIN …-----` / - `-----END …-----` markers. -- **Diagnostic output on stderr** via `tracing` (already a - dependency). A `--debug` flag switches the subscriber from - the default `info` level to `debug`, giving deployers - detailed timing and key-fingerprint output without polluting - the PEM stream on stdout. -- Uses `clap` (already a dependency) for polished `--help` - output and future extensibility (e.g., `--bits`, `--out-dir`). -- Reuses the same `rsa` + `pkcs8` code path as the ephemeral - generator in `src/jwt.rs`. - -##### Container integration - -The container entry script (`share/container/entry_script_sh`) -auto-generates persistent keys on first boot (runs **after** -`adduser`, so the `torrust` user already exists): +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). -```sh -# 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 -``` +**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 | -Hardening notes: -- **`mkdir -p "$auth_dir"` with `chmod 0700`** creates the - `auth/` subdirectory with restrictive permissions before any - key material is written. -- **`mktemp` + `chmod 0600`** creates the temp file with a - unique name and restrictive permissions immediately, so key - material is never world-readable — even momentarily. -- **`[ ! -s … ]`** (not `[ ! -f … ]`) checks that the file - exists **and** is non-empty, protecting against a previous run - that was killed mid-write and left a 0-byte PEM file. -- **`trap … EXIT`** ensures the temp file is cleaned up even if - the script is interrupted between write and `rm`. `/tmp` in - the container is typically `tmpfs` (RAM-backed), so the key - material never touches persistent storage. -- **`sed` patterns match the exact PEM markers** (`BEGIN PRIVATE - KEY` / `BEGIN PUBLIC KEY`) produced by PKCS#8 / SPKI encoding, - rather than loose substrings. -- **stderr flows to the container log** — only stdout is - redirected to the temp file, so diagnostic `tracing` output - and any error messages from the binary are visible in - `docker logs`. If generation fails, the script exits - non-zero. +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 currently performed inline at three entry points. +Phase 7 consolidates these into a single code path. + +**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 `[ ! -s … ]` check and - overwrite each other's keys. This is unlikely in practice - (single-container deployments are the norm), but can be - mitigated with `flock` if needed. - -Because `/etc/torrust/index` is a declared `VOLUME`, the -generated keys persist across container restarts and image -upgrades. Sessions survive as long as the volume is retained. - -All **container** configuration files (those with `container` in -the name under `share/default/config/`) set: -```toml -[auth] -private_key_path = "/etc/torrust/index/auth/private.pem" -public_key_path = "/etc/torrust/index/auth/public.pem" -``` + volume, both could pass the check and overwrite each other's + keys. Mitigate with `flock` if needed; single-container + deployments are the norm. -##### Containerfile changes +Container configs set `auth.private_key_path` / +`auth.public_key_path` to the generated paths. Sessions persist +via the `/etc/torrust/index` volume. -The `torrust-generate-auth-keypair` binary is copied into `/usr/bin/` in -both the debug and release runtime images alongside -`torrust-index` and `health_check`: - -```dockerfile -# Extract and Test (debug) — add to the existing cp -l line: -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 - -# Extract and Test (release) — add to the existing cp -l block: -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/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair -``` +#### Containerfile -Note: the debug stage currently copies only `torrust-index` -(no `health_check`). The new binary follows the same pattern. -The release stage already copies both `torrust-index` and -`health_check`, so the new binary is appended to that block. +`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 (custom key workflow) +#### Host-supplied keys -Hosts who want to use their own RSA key pair have two options: +Two workflows: -1. **Pre-supply before first boot.** Mount or copy key files into - the `/etc/torrust/index` volume before starting the container. - The entry script's existence check (`[ ! -s … ]`) will skip - generation and the server will use the host's keys directly. +1. **Pre-supply:** mount or copy keys to the volume before first + boot. The `[ ! -s … ]` check skips generation. -2. **Overwrite after first boot.** Let the container auto-generate - keys on first boot since it "just works". Later, replace the - generated PEM files on the volume with the host's own keys and - restart the container. The server picks up the new keys; any - tokens signed with the old keys are invalidated (users - re-login once). +2. **Overwrite:** let the container auto-generate on first boot, + then replace the PEM files and restart. -##### Usage outside containers +#### Usage outside containers ```sh tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) @@ -702,181 +280,229 @@ rm -f "$tmpfile" > when the pipeline exits — producing truncated PEM files. > The POSIX version above is strictly correct. -##### Files affected +### Phase 7 — Consolidate Session Validation ⬜ Pending + +#### 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/bin/generate_auth_keypair.rs` | New binary | -| `Cargo.toml` | Add `[[bin]]` section: `name = "torrust-generate-auth-keypair"`, `path = "src/bin/generate_auth_keypair.rs"` | -| `Containerfile` | Copy `torrust-generate-auth-keypair` into `/app/bin/` in both debug and release stages | -| `share/container/entry_script_sh` | Add key-generation block before `exec su-exec` | -| `share/default/config/index.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | -| `share/default/config/index.container.mysql.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | -| `share/default/config/index.public.e2e.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | -| `share/default/config/index.public.e2e.container.mysql.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | -| `share/default/config/index.private.e2e.container.sqlite3.toml` | Add `private_key_path` / `public_key_path` to `[auth]` | - -Note: there is no `index.private.e2e.container.mysql.toml` at -present. If one is added in the future, it will also need the -`[auth]` key paths. - -##### No breaking changes - -This phase adds a new binary and updates the container entry -script. Bare-metal deployments without key paths configured -continue to use Phase 5's ephemeral in-memory keys. Container -deployments gain automatic persistent keys with no manual setup. - -### Configuration Migration - -Deployers upgrading across Phase 2 / Phase 3 must: - -1. Generate an RSA key pair — either via - `cargo run --bin torrust-generate-auth-keypair | …` (see Phase 6) or - externally (`openssl genrsa -out private.pem 2048` and - `openssl rsa -in private.pem -pubout -out public.pem`). -2. Update the config to reference the key paths (or set env vars). -3. Accept that existing sessions will be invalidated (users - re-login once). - -A migration guide will accompany the release that ships Phase 3. - -With Phase 5, steps 1–2 become **optional** for bare-metal -deployments. Without explicit key configuration the server -auto-generates ephemeral keys and functions immediately — -sessions simply do not survive restarts. +| `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.** The entry script generates keys to the -`/etc/torrust/index` volume on first boot; the container configs -already point to the generated paths. No manual key generation or -config editing is required. Sessions persist across restarts as -long as the volume is retained. - -Note: the **serialized default config** changes in Phase 5 — the -bare-metal `[auth]` section will no longer contain -`private_key_path` / `public_key_path` entries. Container configs -*do* include these paths (pointing to `/etc/torrust/index/auth/`). -Deployers who generate their config from defaults should be aware -of this difference. Existing configs that explicitly set these -fields are unaffected. +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 user sessions **will be invalidated** when Phase 2 - ships (claim format change) and again if key material changes - in Phase 3. Users must re-login. -- **Container deployments** auto-generate persistent keys on - first boot (Phase 6). Sessions survive restarts with no manual - setup. Hosts who want their own keys pre-populate the volume or - overwrite the generated keys and restart. -- **Bare-metal deployments** without key paths configured use - ephemeral in-memory keys (Phase 5) — sessions do not survive - restarts. Deployers who want persistent sessions generate a key - pair via `torrust-generate-auth-keypair` (Phase 6) or `openssl` and - configure the paths. -- Token revocation via a `token_generation` counter is included - (Phase 4 / Option E). Password changes, role changes, and bans - increment the counter and invalidate outstanding tokens. -- The centralised `jwt` module makes future algorithm changes - (e.g., migrating to EdDSA) a localised, single-module change. -- External services can verify tokens using only the public key, - enabling zero-trust verification without secret sharing. +- 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 **does not ship any pre-generated RSA key material**. -Tests exercise three key-provisioning modes: - -### Crate-level tests (`src/tests/jwt.rs`) - -The existing `jwt_service()` helper constructs a `JsonWebToken` -with **no key paths configured**, exercising the ephemeral -in-memory generation code path (Phase 5). All round-trip, claim, -and error-path tests work unchanged — they only need a valid -`JsonWebToken` instance, regardless of how the keys were -provisioned. - -### Isolated e2e tests (bare-metal path) - -Isolated e2e tests (the default `cargo test` mode) start an -in-process server with a `TempDir`-based ephemeral configuration. -No key paths are configured, so the server auto-generates keys in -memory. Authentication works for the lifetime of the test process. -No special setup is required. - -### Container e2e tests (persistent-key path) - -Container e2e tests (`compose.yaml`) exercise the production-like -flow where `torrust-generate-auth-keypair` runs in the entry script: - -1. The entry script detects no keys on the `/etc/torrust/index` - volume and runs `torrust-generate-auth-keypair` to create them. -2. The container configs point `auth.private_key_path` and - `auth.public_key_path` at the generated files. -3. The server starts with host-supplied (volume-persisted) keys. -4. E2e tests run the full auth round-trip: register, login, - authenticated requests. - -Because the keys live on a volume, restarting the container -reuses the same key pair — proving session persistence across -restarts (the production contract). - -### Host-supplied-key e2e test - -A dedicated e2e test verifies that externally generated keys are -accepted. The test has an external dependency on **`openssl`** -(must be on `$PATH`). - -**Test outline (`tests/e2e/web/api/v1/contexts/user/`)**: - -1. **Generate a fresh RSA key pair via `openssl`** into a - temporary directory (`tempfile::TempDir`): - ```sh - openssl genrsa -out "$tmpdir/private.pem" 2048 - openssl rsa -in "$tmpdir/private.pem" -pubout -out "$tmpdir/public.pem" - ``` - Executed with `std::process::Command`. The test is - `#[ignore]`-gated (or behind a feature flag / env var) so CI - runners without `openssl` can skip it gracefully. - -2. **Start a test environment** with config overrides pointing - `auth.private_key_path` and `auth.public_key_path` at the - generated files. - -3. **Perform a full auth round-trip:** - - Register a user. - - Log in and receive a session JWT. - - Call an authenticated endpoint using the token. - - Verify the response succeeds (the host-supplied key pair is - used for signing and verification). - -4. **Restart the server** (same key pair, same temp dir) and - confirm the previously issued JWT is **still valid** — proving - session persistence across restarts. - -5. **Cleanup** — the `TempDir` drops automatically, removing the - generated keys. - -This test proves: -- The repository contains no key material and the server boots - without shipped keys. -- `openssl`-generated keys are accepted by - `EncodingKey::from_rsa_pem` / `DecodingKey::from_rsa_pem`. -- Sessions persist across restarts when the deployer supplies - their own keys. - -## Remaining Issues - -- **Problem #11 (`BearerToken` extractor returns `Ok(None)`).** - ✅ **Resolved.** The `BearerToken` extractor now implements - `FromRequestParts` directly and **rejects** missing - (`AuthError::TokenNotFound`) or malformed - (`AuthError::TokenInvalid`) `Authorization` headers at the - extraction boundary. The `Extract` wrapper has been removed. - `ExtractLoggedInUser` uses `BearerToken` directly (fails if - missing). `ExtractOptionalLoggedInUser` catches the rejection - and returns `None` for anonymous requests. - `Authentication::get_user_id_from_bearer_token` now takes - `BearerToken` (not `Option`), eliminating the - `None`-handling indirection. +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/src/config/v2/auth.rs b/src/config/v2/auth.rs index ca2f0afd3..a52000b50 100644 --- a/src/config/v2/auth.rs +++ b/src/config/v2/auth.rs @@ -1,6 +1,7 @@ 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; @@ -123,6 +124,7 @@ impl Auth { 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 @@ -151,6 +153,7 @@ impl Auth { 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"); } None diff --git a/src/config/v2/mod.rs b/src/config/v2/mod.rs index 20a6edbea..8307f2b45 100644 --- a/src/config/v2/mod.rs +++ b/src/config/v2/mod.rs @@ -111,8 +111,18 @@ impl Settings { let _ = self.database.connect_url.set_password(Some("***")); } "***".clone_into(&mut self.mail.smtp.credentials.password); - self.auth.private_key_pem = Some("***-redacted-private-key-pem***".to_owned()); - self.auth.private_key_path = Some("***-redacted***".to_owned()); + 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 49d81dc3d..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,24 @@ 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; diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 49552ee56..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,13 +403,33 @@ 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(|(v,): (i64,)| u64::try_from(v).unwrap_or(0)) - .map_err(|_| database::Error::UserNotFound) + .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> { diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 3b51faa94..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,13 +401,33 @@ 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(|(v,): (i64,)| u64::try_from(v).unwrap_or(0)) - .map_err(|_| database::Error::UserNotFound) + .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> { diff --git a/src/services/authentication.rs b/src/services/authentication.rs index ecb34b830..e66d44efe 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -128,10 +128,15 @@ impl Service { // Verify if token is valid let claims = self.json_web_token.verify(token)?; - // Validate token generation — reject revoked tokens + // Validate token generation — reject revoked tokens (ADR-T-007 §A-1: exact match) let current_gen = self.database.get_token_generation(claims.sub).await?; - if claims.token_gen < current_gen { + if claims.token_gen != current_gen { + return Err(AuthError::TokenRevoked); + } + + // Defence-in-depth: reject tokens for banned users (ADR-T-007 §A-3) + if self.database.is_user_banned(claims.sub).await.unwrap_or(false) { return Err(AuthError::TokenRevoked); } @@ -179,6 +184,18 @@ impl DbUserAuthenticationRepository { 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. /// diff --git a/src/services/user.rs b/src/services/user.rs index ed37e05dd..e1a672a15 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -271,13 +271,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) - .await?; - - // Invalidate all outstanding session tokens for this user - self.user_authentication_repository - .increment_token_generation(user_id) + .change_password_and_revoke_tokens(user_id, &password_hash) .await?; Ok(()) @@ -327,11 +323,9 @@ impl BanService { .get_user_profile_from_username(username_to_be_banned) .await?; - self.banned_user_list.add(&user_profile.user_id).await?; - - // Invalidate all outstanding session tokens for the banned user + // Atomically ban and revoke tokens (ADR-T-007 §A-2c) self.banned_user_list - .increment_token_generation(&user_profile.user_id) + .add_and_revoke_tokens(&user_profile.user_id) .await?; Ok(()) @@ -475,9 +469,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?; - // Invalidate outstanding session tokens — the user's role changed. - self.database.increment_token_generation(*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. @@ -580,6 +573,27 @@ impl DbBannedUserList { 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. + /// + /// # Panics + /// + /// It panics if the expiration date cannot be parsed. + pub async fn add_and_revoke_tokens(&self, user_id: &UserId) -> Result<(), Error> { + let reason = "no reason".to_string(); + + 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_and_revoke_tokens(*user_id, &reason, date_expiry) + .await + } + /// Increment the user's `token_generation` counter, invalidating all /// outstanding session tokens. /// diff --git a/src/web/api/server/v1/auth.rs b/src/web/api/server/v1/auth.rs index fdfb4f042..f7f360563 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -155,7 +155,7 @@ impl Authentication { /// 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.verify(&token.value())?; + let claims = self.json_web_token.verify(token.as_str())?; self.validate_token_generation(&claims).await?; Ok(claims.sub) } @@ -164,10 +164,23 @@ impl Authentication { /// `token_generation` in the database. Returns `AuthError::TokenRevoked` /// if the token is stale (e.g. after a password change, role change, /// or ban). + /// + /// Uses exact-match (`!=`) rather than `<` so that tokens are also + /// rejected when the database generation *decreases* (e.g. restore + /// from backup). See ADR-T-007 §A-1. + /// + /// Also checks the ban table as a defence-in-depth measure + /// (ADR-T-007 §A-3). async fn validate_token_generation(&self, claims: &SessionClaims) -> Result<(), AuthError> { let current_gen = self.database.get_token_generation(claims.sub).await?; - if claims.token_gen < current_gen { + if claims.token_gen != current_gen { + return Err(AuthError::TokenRevoked); + } + + // Defence-in-depth: reject tokens for banned users even if + // token_generation somehow matches (ADR-T-007 §A-3). + if self.database.is_user_banned(claims.sub).await.unwrap_or(false) { return Err(AuthError::TokenRevoked); } @@ -184,7 +197,7 @@ impl Authentication { pub fn parse_token(authorization: &HeaderValue) -> Result { let header_str = authorization.to_str().map_err(|_| AuthError::TokenInvalid)?; - let token = header_str.strip_prefix("Bearer").ok_or(AuthError::TokenInvalid)?.trim(); + let token = header_str.strip_prefix("Bearer ").ok_or(AuthError::TokenInvalid)?.trim(); if token.is_empty() { return Err(AuthError::TokenInvalid); diff --git a/src/web/api/server/v1/contexts/category/mod.rs b/src/web/api/server/v1/contexts/category/mod.rs index 6ae5902c7..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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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 8b99a8ee3..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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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 c64e2cadd..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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --header "Authorization: Bearer " \ //! --request GET \ //! "http://127.0.0.1:3001/v1/settings" //! ``` diff --git a/src/web/api/server/v1/contexts/tag/mod.rs b/src/web/api/server/v1/contexts/tag/mod.rs index c26d4995a..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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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 11a6edc65..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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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 233d424ef..605de6972 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -72,7 +72,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>, @@ -95,7 +95,7 @@ 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. +/// - The JWT is invalid or expired. /// - The token's generation has been revoked. pub async fn verify_token_handler( State(app_data): State>, @@ -106,12 +106,18 @@ pub async fn verify_token_handler( Err(error) => return error.into_response(), }; - // Validate token generation against the database - let Ok(current_gen) = app_data.database.get_token_generation(claims.sub).await else { - return AuthError::UserNotFound.into_response(); + // Validate token generation against the database (ADR-T-007 §A-1: exact match) + let current_gen = match app_data.database.get_token_generation(claims.sub).await { + Ok(generation) => generation, + Err(e) => return AuthError::from(e).into_response(), }; - if claims.token_gen < current_gen { + if claims.token_gen != current_gen { + return AuthError::TokenRevoked.into_response(); + } + + // Defence-in-depth: reject tokens for banned users (ADR-T-007 §A-3) + if app_data.database.is_user_banned(claims.sub).await.unwrap_or(false) { return AuthError::TokenRevoked.into_response(); } @@ -131,7 +137,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 a69dba5b0..4cc25770a 100644 --- a/src/web/api/server/v1/contexts/user/mod.rs +++ b/src/web/api/server/v1/contexts/user/mod.rs @@ -123,7 +123,7 @@ //! //! Name | Type | Description | Required | Example //! ---|---|---|---|--- -//! `token` | `String` | The token you want to verify | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak` +//! `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. @@ -134,7 +134,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request POST \ -//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak"}' \ +//! --data '{"token":""}' \ //! http://127.0.0.1:3001/v1/user/token/verify //! ``` //! @@ -169,7 +169,7 @@ //! //! Name | Type | Description | Required | Example //! ---|---|---|---|--- -//! `token` | `String` | The current valid token | Yes | `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak` +//! `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. @@ -180,7 +180,7 @@ //! curl \ //! --header "Content-Type: application/json" \ //! --request POST \ -//! --data '{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak"}' \ +//! --data '{"token":""}' \ //! http://127.0.0.1:3001/v1/user/token/renew //! ``` //! @@ -191,7 +191,7 @@ //! ```json //! { //! "data": { -//! "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak", +//! "token": "", //! "username": "indexadmin", //! "admin": true //! } @@ -225,7 +225,7 @@ //! ```bash //! curl \ //! --header "Content-Type: application/json" \ -//! --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6InRvcnJ1c3QtaW5kZXgiLCJhdWQiOiJzZXNzaW9uIiwiaWF0IjoxNjg2MjE1Nzg4LCJleHAiOjE2ODc0MjUzODgsInJvbGUiOiJhZG1pbiIsInVzZXJuYW1lIjoiaW5kZXhhZG1pbiJ9.-EfY9CrZz2OLfjiVQzkhxSjV7tWTFivP2yMuzZkbEak" \ +//! --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 f463cfe1c..0bf06ba8e 100644 --- a/src/web/api/server/v1/extractors/bearer_token.rs +++ b/src/web/api/server/v1/extractors/bearer_token.rs @@ -23,8 +23,8 @@ pub struct BearerToken(String); impl BearerToken { #[must_use] - pub fn value(&self) -> String { - self.0.clone() + pub fn as_str(&self) -> &str { + &self.0 } } diff --git a/tests/e2e/environment.rs b/tests/e2e/environment.rs index 71f691d0b..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); - settings.auth.private_key_path = Some("***-redacted***".to_owned()); + 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) } From 593276aac6d361bf9e37869675af17cd08242883 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 16 Apr 2026 09:26:06 +0200 Subject: [PATCH 09/10] refactor(jwt): consolidate session validation into single code path (ADR-T-007 Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `JsonWebToken::validate_session` as the sole entry point for session-token validation — JWT signature/expiry verification, token- generation counter check, and banned-user rejection all happen in one place. Three call sites that previously re-implemented this sequence now delegate to `validate_session`: - `Authentication::get_user_id_from_bearer_token` - `verify_token_handler` - `authentication::Service::renew_token` This removes ~45 lines of duplicated validation logic and eliminates the private `Authentication::validate_token_generation` helper. Mark ADR-T-007 Phase 7 as complete. --- CHANGELOG.md | 6 +-- README.md | 2 +- adr/007-jwt-system-refactor.md | 8 ++-- src/jwt.rs | 43 +++++++++++++++++-- src/services/authentication.rs | 27 ++++-------- src/services/user.rs | 8 +--- src/web/api/server/v1/auth.rs | 34 ++------------- .../api/server/v1/contexts/user/handlers.rs | 34 +++++---------- 8 files changed, 71 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ed8c981..be6373e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. -- Revocation checks at three entry points (defence in depth): - `Authentication::get_user_id_from_bearer_token`, `verify_token_handler`, - and `authentication::Service::renew_token`. +- 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` diff --git a/README.md b/README.md index 8d1788e4c..f967295a5 100644 --- a/README.md +++ b/README.md @@ -142,7 +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, and move to RS256 asymmetric signing with a public/private RSA key pair. +- [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 index 88afca483..d5d538c84 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -1,6 +1,6 @@ # ADR-T-007: Refactor the JWT System -**Status:** Phases 1–6 implemented · Phase 7 pending +**Status:** Phases 1–7 implemented **Date:** 2026-04-14 **Updated:** 2026-04-16 @@ -186,8 +186,8 @@ 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 currently performed inline at three entry points. -Phase 7 consolidates these into a single code path. +Validation is consolidated into `JsonWebToken::validate_session` +(Phase 7). **Breaking:** tokens without a `gen` claim fail deserialization. @@ -280,7 +280,7 @@ rm -f "$tmpfile" > when the pipeline exits — producing truncated PEM files. > The POSIX version above is strictly correct. -### Phase 7 — Consolidate Session Validation ⬜ Pending +### Phase 7 — Consolidate Session Validation ✅ #### Problem diff --git a/src/jwt.rs b/src/jwt.rs index 9ec1da60f..511e83b34 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -5,7 +5,7 @@ //! //! See ADR-T-007 for the rationale behind centralising JWT handling. //! -//! # Architecture (ADR-T-007 Phases 1–6) +//! # Architecture (ADR-T-007 Phases 1–7) //! //! **Phase 1 — Structural cleanup.** Consolidated all `jsonwebtoken` //! usage into this single module with `Result`-based error propagation. @@ -39,6 +39,12 @@ //! 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; @@ -50,6 +56,7 @@ 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; @@ -87,9 +94,10 @@ pub struct SessionClaims { pub role: String, /// Advisory username. Non-authoritative. pub username: String, - /// Token generation counter. Tokens with a `gen` older than the - /// current database value for this user are considered revoked. - /// See ADR-T-007 Phase 4 (Optional Revocation). + /// 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, } @@ -243,6 +251,33 @@ 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. + /// + /// # 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.unwrap_or(false) { + 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 diff --git a/src/services/authentication.rs b/src/services/authentication.rs index e66d44efe..989108c77 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -3,12 +3,14 @@ //! Provides login (username + password → JWT) and token renewal. //! JWT signing and verification are delegated to [`crate::jwt::JsonWebToken`]. //! -//! ## Token revocation (ADR-T-007 Phase 4) +//! ## Token revocation (ADR-T-007 Phases 4 & 7) //! -//! On login and renewal, the service fetches the user's current +//! On login, the service fetches the user's current //! `token_generation` from the database and embeds it in the JWT -//! (`gen` claim). On renewal, tokens whose `gen` is older than the -//! database value are rejected as revoked. +//! (`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}; @@ -125,20 +127,7 @@ 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)?; - - // Validate token generation — reject revoked tokens (ADR-T-007 §A-1: exact match) - let current_gen = self.database.get_token_generation(claims.sub).await?; - - if claims.token_gen != current_gen { - return Err(AuthError::TokenRevoked); - } - - // Defence-in-depth: reject tokens for banned users (ADR-T-007 §A-3) - if self.database.is_user_banned(claims.sub).await.unwrap_or(false) { - return Err(AuthError::TokenRevoked); - } + 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, @@ -147,7 +136,7 @@ impl Service { // 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(), current_gen).await?, + x if x < ONE_WEEK_IN_SECONDS => self.json_web_token.sign(user_compact.clone(), claims.token_gen).await?, _ => token.to_string(), }; diff --git a/src/services/user.rs b/src/services/user.rs index e1a672a15..7d269303d 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -324,9 +324,7 @@ impl BanService { .await?; // Atomically ban and revoke tokens (ADR-T-007 §A-2c) - self.banned_user_list - .add_and_revoke_tokens(&user_profile.user_id) - .await?; + self.banned_user_list.add_and_revoke_tokens(&user_profile.user_id).await?; Ok(()) } @@ -589,9 +587,7 @@ impl DbBannedUserList { 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_and_revoke_tokens(*user_id, &reason, date_expiry) - .await + self.database.ban_user_and_revoke_tokens(*user_id, &reason, date_expiry).await } /// Increment the user's `token_generation` counter, invalidating all diff --git a/src/web/api/server/v1/auth.rs b/src/web/api/server/v1/auth.rs index f7f360563..505cb600e 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -72,7 +72,9 @@ //! 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 (see ADR-T-007 Phase 4). +//! 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). @@ -155,37 +157,9 @@ impl Authentication { /// 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.verify(token.as_str())?; - self.validate_token_generation(&claims).await?; + let claims = self.json_web_token.validate_session(&**self.database, token.as_str()).await?; Ok(claims.sub) } - - /// Checks that the token's `gen` claim matches the current - /// `token_generation` in the database. Returns `AuthError::TokenRevoked` - /// if the token is stale (e.g. after a password change, role change, - /// or ban). - /// - /// Uses exact-match (`!=`) rather than `<` so that tokens are also - /// rejected when the database generation *decreases* (e.g. restore - /// from backup). See ADR-T-007 §A-1. - /// - /// Also checks the ban table as a defence-in-depth measure - /// (ADR-T-007 §A-3). - async fn validate_token_generation(&self, claims: &SessionClaims) -> Result<(), AuthError> { - let current_gen = self.database.get_token_generation(claims.sub).await?; - - if claims.token_gen != current_gen { - return Err(AuthError::TokenRevoked); - } - - // Defence-in-depth: reject tokens for banned users even if - // token_generation somehow matches (ADR-T-007 §A-3). - if self.database.is_user_banned(claims.sub).await.unwrap_or(false) { - return Err(AuthError::TokenRevoked); - } - - Ok(()) - } } /// Parses the token from the `Authorization` header. diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index 605de6972..31f8a70b3 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -11,7 +11,6 @@ use serde::Deserialize; use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm}; use super::responses::{self}; use crate::common::AppData; -use crate::errors::AuthError; use crate::services::user::ListingRequest; use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser; use crate::web::api::server::v1::responses::OkResponseData; @@ -101,30 +100,17 @@ pub async fn verify_token_handler( State(app_data): State>, extract::Json(token): extract::Json, ) -> Response { - let claims = match app_data.json_web_token.verify(&token.token) { - Ok(claims) => claims, - Err(error) => return error.into_response(), - }; - - // Validate token generation against the database (ADR-T-007 §A-1: exact match) - let current_gen = match app_data.database.get_token_generation(claims.sub).await { - Ok(generation) => generation, - Err(e) => return AuthError::from(e).into_response(), - }; - - if claims.token_gen != current_gen { - return AuthError::TokenRevoked.into_response(); - } - - // Defence-in-depth: reject tokens for banned users (ADR-T-007 §A-3) - if app_data.database.is_user_banned(claims.sub).await.unwrap_or(false) { - return AuthError::TokenRevoked.into_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(), } - - axum::Json(OkResponseData { - data: "Token is valid.".to_string(), - }) - .into_response() } #[derive(Deserialize)] From 6126eec3e1e08152130c69960b328848320a8fa3 Mon Sep 17 00:00:00 2001 From: Peer Cat Date: Thu, 16 Apr 2026 10:18:36 +0200 Subject: [PATCH 10/10] fix(auth): harden token parsing and remove panics in ban logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse Bearer scheme case-insensitively per RFC 7235 §2.1 - Replace `parse_from_str` + `expect` with a `LazyLock` constant for the permanent-ban expiry date, removing two panic sites - Propagate `is_user_banned` errors with `?` instead of swallowing them via `unwrap_or(false)` - Use `saturating_sub` for token-expiry arithmetic to avoid underflow --- src/jwt.rs | 2 +- src/services/authentication.rs | 2 +- src/services/user.rs | 33 ++++++++++++++------------------- src/web/api/server/v1/auth.rs | 9 +++++++-- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/jwt.rs b/src/jwt.rs index 511e83b34..2787ebdb9 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -271,7 +271,7 @@ impl JsonWebToken { return Err(AuthError::TokenRevoked); } - if db.is_user_banned(claims.sub).await.unwrap_or(false) { + if db.is_user_banned(claims.sub).await? { return Err(AuthError::TokenRevoked); } diff --git a/src/services/authentication.rs b/src/services/authentication.rs index 989108c77..49353a781 100644 --- a/src/services/authentication.rs +++ b/src/services/authentication.rs @@ -135,7 +135,7 @@ impl Service { })?; // Renew token if it is valid for less than one week - let token = match claims.exp - clock::now() { + 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(), }; diff --git a/src/services/user.rs b/src/services/user.rs index 7d269303d..4e0ebfd62 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -1,10 +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 chrono::NaiveDate; #[cfg(test)] use mockall::automock; use pbkdf2::password_hash::rand_core::OsRng; @@ -24,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. @@ -551,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. @@ -564,11 +568,7 @@ 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, date_expiry).await + self.database.ban_user(*user_id, &reason, *PERMANENT_BAN_EXPIRY).await } /// Ban a user and atomically increment `token_generation`. @@ -577,17 +577,12 @@ impl DbBannedUserList { /// # Errors /// /// It returns an error if there is a database error. - /// - /// # Panics - /// - /// It panics if the expiration date cannot be parsed. pub async fn add_and_revoke_tokens(&self, user_id: &UserId) -> Result<(), Error> { let reason = "no reason".to_string(); - 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_and_revoke_tokens(*user_id, &reason, date_expiry).await + self.database + .ban_user_and_revoke_tokens(*user_id, &reason, *PERMANENT_BAN_EXPIRY) + .await } /// Increment the user's `token_generation` counter, invalidating all diff --git a/src/web/api/server/v1/auth.rs b/src/web/api/server/v1/auth.rs index 505cb600e..711724c15 100644 --- a/src/web/api/server/v1/auth.rs +++ b/src/web/api/server/v1/auth.rs @@ -167,11 +167,16 @@ impl Authentication { /// # Errors /// /// Returns `AuthError::TokenInvalid` if the header value is not valid -/// ASCII or does not contain a `Bearer ` pair. +/// 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.strip_prefix("Bearer ").ok_or(AuthError::TokenInvalid)?.trim(); + 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);