Security and quality fixes #49

Merged
eliott merged 12 commits from feature/security-and-quality-fixes into develop 2026-05-12 22:09:55 +02:00
Owner

Critical and Important fixes from a multi-agent code review (1 of 3 repos).

Auth and session

  • JWT lifetime validation + ClockSkew=30s
  • RefreshToken: drop 1:1 unique (multi-device), absolute session cap via OriginalCreatedAt, signature-first validation
  • Normalized JWT claim shapes: permissions=number, emailActive/accountActive=booleans, roles=array
  • Uniform 401 'Identifiants invalides' on bad creds (no username enumeration)
  • AccountDisabledException / EmailNotVerifiedException for distinct 403s
  • Password lockout with time-based unlock (LastFailedLoginAt)
  • ResetPassword burns the token; UpdatePassword resets FailCount
  • Permission.Value: int -> long (more room beyond 31 bits)

Errors and validation

  • ApiResponse unified error envelope from GlobalExceptionHandler
  • Model-binding validation also returns ApiResponse (InvalidModelStateResponseFactory)
  • New exception types wired; FK violation -> 409

Validators and crypto

  • Password regex rewritten (12 chars + digit + upper + lower + symbol; symbol is any non-alphanumeric)
  • Color validator inverted-logic fix
  • Randomized AES-GCM for non-searchable PII (FirstName / LastName)
  • Cached SymmetricSecurityKey and derived encryption keys

Document upload hardening

  • Magic-byte sniffing (PNG / JPEG / GIF / WebP / PDF)
  • Extension allowlist derived from sniffed mime
  • SVG removed (XSS surface)
  • 5 MiB size cap

Cache

  • Per-key SemaphoreSlim to coalesce stampedes
  • Tag-based invalidation (replaces KEYS * scan)
  • users:model / users:dto key split (collision fix)
  • RespirationExercise cache-key typo fix

Repository

  • Repository.UpdateAsync: targeted column updates (excludes CreatedAt); UpdateFieldsAsync overload
  • StressDiagnosticService: load from DB before update (no more cached-model rewrites)

Operations

  • Rate limiting on auth endpoints (10/min/IP, fixed window)
  • OnRejected writes ApiResponse body + Retry-After header
  • /health endpoint (DbContext check)
  • .env.local in .gitignore (preserves any historical tracking)

EF migration

  • AllowMultipleRefreshTokensPerUser: drops unique IX on RefreshTokens.UserId
  • AddRefreshTokenOriginalCreatedAt
  • Permissions.Value int -> bigint
  • Passwords.LastFailedLoginAt added

Verification

  • dotnet build clean (warnings pre-existing)
  • Live integration tests: /health=200; uniform 401 envelope; new password rule rejects weak / accepts strong; rate limit 429 with body + Retry-After; ProblemDetails unification works on 3 validation paths; only expected exception types in API log
  • 13 commits, +4122 / -494 across 57 files
Critical and Important fixes from a multi-agent code review (1 of 3 repos). ## Auth and session - JWT lifetime validation + ClockSkew=30s - RefreshToken: drop 1:1 unique (multi-device), absolute session cap via OriginalCreatedAt, signature-first validation - Normalized JWT claim shapes: permissions=number, emailActive/accountActive=booleans, roles=array - Uniform 401 'Identifiants invalides' on bad creds (no username enumeration) - AccountDisabledException / EmailNotVerifiedException for distinct 403s - Password lockout with time-based unlock (LastFailedLoginAt) - ResetPassword burns the token; UpdatePassword resets FailCount - Permission.Value: int -> long (more room beyond 31 bits) ## Errors and validation - ApiResponse<ErrorData> unified error envelope from GlobalExceptionHandler - Model-binding validation also returns ApiResponse<ErrorData> (InvalidModelStateResponseFactory) - New exception types wired; FK violation -> 409 ## Validators and crypto - Password regex rewritten (12 chars + digit + upper + lower + symbol; symbol is any non-alphanumeric) - Color validator inverted-logic fix - Randomized AES-GCM for non-searchable PII (FirstName / LastName) - Cached SymmetricSecurityKey and derived encryption keys ## Document upload hardening - Magic-byte sniffing (PNG / JPEG / GIF / WebP / PDF) - Extension allowlist derived from sniffed mime - SVG removed (XSS surface) - 5 MiB size cap ## Cache - Per-key SemaphoreSlim to coalesce stampedes - Tag-based invalidation (replaces KEYS * scan) - users:model / users:dto key split (collision fix) - RespirationExercise cache-key typo fix ## Repository - Repository.UpdateAsync: targeted column updates (excludes CreatedAt); UpdateFieldsAsync overload - StressDiagnosticService: load from DB before update (no more cached-model rewrites) ## Operations - Rate limiting on auth endpoints (10/min/IP, fixed window) - OnRejected writes ApiResponse<ErrorData> body + Retry-After header - /health endpoint (DbContext check) - .env.local in .gitignore (preserves any historical tracking) ## EF migration - AllowMultipleRefreshTokensPerUser: drops unique IX on RefreshTokens.UserId - AddRefreshTokenOriginalCreatedAt - Permissions.Value int -> bigint - Passwords.LastFailedLoginAt added ## Verification - dotnet build clean (warnings pre-existing) - Live integration tests: /health=200; uniform 401 envelope; new password rule rejects weak / accepts strong; rate limit 429 with body + Retry-After; ProblemDetails unification works on 3 validation paths; only expected exception types in API log - 13 commits, +4122 / -494 across 57 files
- 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.
eliott merged commit f22dc514ea into develop 2026-05-12 22:09:55 +02:00
eliott deleted branch feature/security-and-quality-fixes 2026-05-12 22:09:55 +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!49
No description provided.