Release: security and quality fixes #50

Merged
eliott merged 17 commits from develop into main 2026-05-12 22:34:17 +02:00
Owner

Promotes the security & quality fixes from develop to main.

Includes API PR #49 (merged): JWT lifetime validation, refresh-token multi-device + absolute session cap, normalized claim shapes, uniform 401 login, ApiResponse error envelope (incl. model-binding), rate limiting on auth endpoints, document-upload magic-byte sniff, randomised AES-GCM for non-searchable PII, cache hardening, /health endpoint, password lockout with time-based unlock, Permission.Value int->long, validator fixes.

13 underlying commits, +4122 / -494 across 57 files. dotnet build clean.

ATTENTION: EF migration on merge

The migration 20260512192455_SecurityAndQualityFixes:

  • DROPs unique IX on RefreshTokens.UserId (multi-device)
  • ADDs RefreshTokens.OriginalCreatedAt column
  • ALTERs Permissions.Value int -> bigint
  • ADDs Passwords.LastFailedLoginAt column

Apply via 'dotnet ef database update' before pointing prod traffic at new build.

Live integration tests on a fresh DB: /health=200; uniform 401 envelope (no enumeration); new password rule rejects weak / accepts strong; rate limit triggers 429 with body + Retry-After: 60; ProblemDetails unification works on 3 validation paths; only expected exception types in API log.

Promotes the security & quality fixes from develop to main. Includes API PR #49 (merged): JWT lifetime validation, refresh-token multi-device + absolute session cap, normalized claim shapes, uniform 401 login, ApiResponse<ErrorData> error envelope (incl. model-binding), rate limiting on auth endpoints, document-upload magic-byte sniff, randomised AES-GCM for non-searchable PII, cache hardening, /health endpoint, password lockout with time-based unlock, Permission.Value int->long, validator fixes. 13 underlying commits, +4122 / -494 across 57 files. dotnet build clean. ## ATTENTION: EF migration on merge The migration 20260512192455_SecurityAndQualityFixes: - DROPs unique IX on RefreshTokens.UserId (multi-device) - ADDs RefreshTokens.OriginalCreatedAt column - ALTERs Permissions.Value int -> bigint - ADDs Passwords.LastFailedLoginAt column Apply via 'dotnet ef database update' before pointing prod traffic at new build. Live integration tests on a fresh DB: /health=200; uniform 401 envelope (no enumeration); new password rule rejects weak / accepts strong; rate limit triggers 429 with body + Retry-After: 60; ProblemDetails unification works on 3 validation paths; only expected exception types in API log.
- JwtUtils.VerifyToken now returns the verified ClaimsPrincipal (or null) and
  enforces ValidateLifetime=true with ClockSkew=30s. Removes the unsafe
  ReadJwtToken public API; callers must use the verified principal path.
- AddJwt in DI explicitly sets ValidateLifetime=true / ClockSkew=30s.
- Claim shapes follow the cross-client contract:
    permissions -> JSON number (long)
    emailActive/accountActive -> JSON booleans
    roles -> JSON array (always an array, even for a single role)
- Permission.Value: int -> long; bitmask helpers / claim parsing follow.
- HashUtils SHA-256-derives keys once via Lazy<byte[]> (no per-call derivation).
- HashUtils, JwtUtils, PasswordUtils, TokenUtils made static.
- Permission filter uses the new in-memory IPermissionCatalog (constant-time
  label -> bit lookup, no per-request DB hit) and a 60s-TTL cached user DTO
  for the defence-in-depth re-check.
- AccountActiveRequirement filter reads the same 60s-TTL cached DTO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Permissions are seeded via migrations and effectively immutable at runtime,
so we lazy-load them once into a ConcurrentDictionary at first use and serve
every subsequent lookup from memory. This removes the per-request DB hit
that PermissionRequirementFilter used to perform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- User <-> RefreshToken is now 1:N (User.RefreshTokens), index on UserId is
  non-unique. A user can be logged in on web AND mobile simultaneously.
- RefreshToken gets OriginalCreatedAt: preserved across rotations so the
  absolute session lifetime can be enforced even when a chain of refreshes
  keeps issuing new short-lived tokens.
- RefreshTokenValidator now: verifies signature/lifetime FIRST, then hash,
  then ValidUntil, then the absolute cap (OriginalCreatedAt + maxDays).
  Throws AuthenticationException so the global handler maps to 401.
- RefreshTokenService rewrite: API takes optional originalCreatedAt on
  generation; DeleteAllForUser deletes every row for a user (multi-device
  logout); the legacy "HandleExistingRefreshToken" delete-by-user is gone.
- Cookie cookie / session lifetime caps live in AuthConstants
  (7 days regular, 30 days remember-me).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GlobalExceptionHandler now emits ApiResponse<ErrorData> for every failure,
matching the success-response envelope. Clients read body.data.message
(and body.data.details only in Development).

New exception types mapped by the handler:
  AuthenticationException -> 401
  AccountDisabledException -> 403 (account deactivated)
  EmailNotVerifiedException -> 403 (email not verified)
  ForbiddenAccessException -> 403 (generic forbid)
  UnauthorizedAccessException -> 403
  KeyNotFoundException -> 404
  ValidationException / BadHttpRequest -> 400
  PostgresException (unique violation) -> 409 (with constraint-name mapping)
  PostgresException (FK violation) -> 409 (with parent-delete message)

ApiResponse.Ok<T>(data, statusCode=200) factory added — controllers can stop
hard-coding 200/204 in the envelope and let the HTTP status be the source
of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Validator.ValidateColor: condition was inverted (it threw on a *valid* hex
  code). Flip the check and fix the "hexadecimal" -> "hexadécimal" typo.
- PasswordValidator: the previous regex required runs of 2/2/2/3 of each
  character class, which is much stricter than intended. Rewrite to the
  contract documented in the XML comment:
    * at least 12 chars
    * at least one digit
    * at least one upper-case letter
    * at least one lower-case letter
    * at least one non-alphanumeric symbol ([^A-Za-z0-9])
  Clients must mirror this in cesizen-front/src/app/auth/constants/password.ts
  and cesizen-mobile/constants/password.ts.
- PositiveValidator: "posifitif" -> "positif".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop image/svg+xml from MimeTypes.ImageMimeTypes (scriptable format, would
  enable stored XSS when served inline).
- Add FileSignatures: known magic-byte signatures for PNG, JPEG/JPG, GIF,
  WebP and PDF, plus a sniffed-mime -> allowed-extension map.
- FileValidator.ValidateFileType: for any mime in the signatures map,
  compare the leading bytes of the upload to the candidate signatures and
  reject mismatches with a ValidationException.
- FileValidator.ValidateFileSize: cap at 5 MiB (was 20 MB).
- S3Service.GenerateKey: build the extension from the sniffed mime via the
  allowlist (^[a-z0-9]{1,5}$), never from the client-supplied filename.
  Refuse null bytes / path-traversal sequences in the id defensively even
  though the result is not used as a filesystem path.
- DocumentController.RequestSizeLimit mirrors the 5 MiB cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- GetOrCreateAsync now coalesces concurrent misses for the same key via a
  refcounted per-key SemaphoreSlim (ConcurrentDictionary). Cache stampede
  protection without unbounded growth.
- New tag-based invalidation: keys can register against one or more "tag"
  Redis Sets at write time; InvalidateTagAsync deletes every key the tag
  set knows about plus the tag set itself. Replaces the KEYS *-style
  full-DB scan for new code (RemoveByPatternAsync kept for back-compat).
- UserCacheKeys constants:
    users:model -> full User EF model (UserService writers)
    users:dto   -> public ReturnUserDto (PermissionService / filter readers)
  Two distinct prefixes prevent the shape-collision that issue #16 flagged.
  Both invalidated together via the "tag:users" tag.
- New SetAsync overload takes params string[] tags; GetOrCreateAsync forwards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- UpdateAsync now attaches the entity as Modified but explicitly excludes
  CreatedAt so a detached / cache-thawed entity cannot clobber the
  original creation timestamp. Also drops the implicit "rewrite every
  column" behaviour for change-tracked entities.
- New UpdateFieldsAsync(model, properties[]) overload — flip only the
  selected properties (useful for partial updates like an IsActive toggle).
- StressDiagnosticService.UpdateDiagnostic: load the diagnostic from the
  database (never from cache) before mutating, so EF's change tracker
  produces a correct UPDATE. The previous code applied changes to a
  cache-deserialized entity, which has no change tracking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- UserService.LogUser: unify error responses for "unknown username" and
  "wrong password" (uniform AuthenticationException with the same French
  message — "Identifiants invalides" — so an attacker can no longer
  enumerate usernames via differing responses). Add explicit IsActive /
  IsEmailVerified checks AFTER credentials validate; throw the new
  AccountDisabledException / EmailNotVerifiedException so the global handler
  emits a 403 with distinct messages and clients can route accordingly.
- UserService.RefreshToken: rewritten — verify signature FIRST, then read
  claims from the verified principal, then DB lookup, then validator (hash
  + ValidUntil + absolute cap). New ValidUntil = min(now + rotation,
  OriginalCreatedAt + maxSession). Also blocks deactivated / unverified
  accounts at refresh time.
- UserService.{UpdateUser,ToggleAccountActivation,DeleteUser,UpdateUserRole}:
  load from DB and use SavesChanges() (or DeleteAsync); centralized
  InvalidateUserCaches() invalidates both user prefixes and the user tag.
- UserService.{EncryptUserInfo}: first/last name now use RandomizedEncrypt
  (AES-GCM with random nonce, non-searchable, no plaintext leak); email
  and username stay deterministic (still searchable in the DB).
  DecryptUserInfo uses HashUtils.DecryptName which tries the new format
  first and falls back to deterministic for existing rows.
- PasswordService rewrite:
    * Add LastFailedLoginAt column for time-based unlock; after
      AuthConstants.FailedLoginResetMinutes (15) the counter resets.
    * Method renamed VerifiyLoginPasswords -> VerifyLoginPasswords (typo).
    * UpdatePassword resets FailCount + LastFailedLoginAt on success.
    * ResetPassword deletes the verification token after a successful reset
      (mirrors EmailService.CheckVerificationEmail).
    * Service now depends on IVerificationTokenRepository (DI updated).
- UserController:
    * [AccountActiveRequirement] applied to UpdateUserEmail (self).
    * GetCurrentUser / DeleteLoggedUser intentionally remain reachable for
      unverified users (recovery path).
    * Migrate to ApiResponse.Ok<T>(data, statusCode) factory.
    * CancellationToken plumbing on every action.
- UserRepository: "Aucun utilisater" typo -> "Aucun utilisateur".
- PermissionService / EmailService: read from / invalidate the new split
  user cache prefixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rate limiting: built-in Microsoft.AspNetCore.RateLimiting; per-IP fixed
  window of 10 req/min on the "auth" policy. Applied via
  [EnableRateLimiting("auth")] to:
    POST /user/login, /user/register, /user/refresh
    POST /password/reset, /password/reset/request
    POST /emails/verification/check
- Health checks: AddCheck<DatabaseHealthCheck> against EF Core's CanConnect,
  exposed at GET /health.
- DocumentController: CancellationToken plumbing + ApiResponse.Ok factory.
- Typo: RespirationExerciseService "respicationExercises" -> "respirationExercises"
  and "Aucun exercise" -> "Aucun exercice".
- .gitignore: also ignore .env.local and .env.*.local.
- Migration AllowMultipleRefreshTokensPerUser+AddRefreshTokenOriginalCreatedAt+
  AddPermissionValueLong+AddLastFailedLoginAt -- single migration
  20260512192455_SecurityAndQualityFixes:
    * RefreshTokens: drop unique IX on UserId, recreate non-unique, add
      OriginalCreatedAt column.
    * Permissions: Value int -> bigint, seeded long literals.
    * Passwords: add LastFailedLoginAt timestamp (nullable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The auth-policy rate limiter previously set RejectionStatusCode=429 but wrote
no body, so clients got an empty 429 response and couldn't surface a meaningful
message via their normal error path.

Wire OnRejected to write ApiResponse<ErrorData> with a French message and a
Retry-After header derived from the lease metadata. The web and mobile clients
already read body.data.message defensively so this lights up automatically.
Live integration testing surfaced that ASP.NET Core's built-in model-binding
validation returns a ProblemDetails envelope ({type, title, status, errors,
traceId}) on 400s — bypassing GlobalExceptionHandler and the new unified
envelope. Clients reading body.data.message defensively would silently fall
through to "undefined".

Wire InvalidModelStateResponseFactory to project ModelState errors into the
same ApiResponse<ErrorData> shape used by every other 4xx, so every validation
error funnels through the same client-side toast path.
Reviewed-on: #49
# Conflicts:
#	Api/Users/Services/UserService.cs
#	Shared/ErrorHandler/GlobalExceptionHandler.cs
#	Shared/Utils/HashUtils.cs
#	Shared/Utils/JwtUtils.cs
eliott merged commit 68231d0961 into main 2026-05-12 22:34:17 +02:00
Commenting is not possible because the repository is archived.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
bloc-cesi/cesizen-api!50
No description provided.