Skip to content

merge(develop): user-management, token-management, auth improvements - #21

Merged
EdgarEldy merged 94 commits into
masterfrom
develop
Nov 9, 2026
Merged

EdgarEldy merged 94 commits into
masterfrom
develop

Conversation

@EdgarEldy

Copy link
Copy Markdown
Owner

Summary

Integrates all feature branches completed since the last master merge:

  • feature/token-management: refresh token system (RefreshToken entity + V12 migration, RefreshTokenService), token blacklisting, email activation flows, SecureTokenGenerator, TokenCleanupScheduler, V11 migration (created_at on password_reset_tokens)
  • feature/user-management: AppUser refactor (Role enum -> JPA Role entity, ManyToMany, enabled/accountLocked), role-based JWT claims, account management endpoints (lock/unlock, updateProfile), activation/forgot-password/reset-password flows, custom validation constraints (@validpassword, @PasswordsMatch, @unique), firstName/lastName fields (V9 migration), DataSeeder bootstrap admin
  • AuthResponse improvements: renamed token -> access_token, dropped username, added expires_in, added refresh_token

Test plan

  • All tests green on develop (347+ tests)
  • SecurityAuthorizationTest.19 (logout blacklist) passes
  • SecurityAuthorizationTest.20 (locked account token) passes

Carries the email address to which a one-time password reset link
will be sent. The email field is validated with @notblank and @Email
so an invalid address is rejected at the HTTP layer before reaching
the service, keeping the error response uniform with other endpoints.
Carries the new password and its confirmation for the reset-password
endpoint. Token is intentionally absent: it travels as a @RequestParam
in the URL (see AuthController) so the frontend can copy it directly
from the reset-link query string without wrapping it in a JSON body.

@PasswordsMatch and @validpassword mirror the constraints already on
RegisterRequest.confirmPassword, ensuring the same complexity rules
apply whether a user sets a password for the first time or resets it.
toString() masks both password fields to prevent them from leaking
into logs via LoggingAspect (same pattern as RegisterRequest).
…ions

Defines the three lifecycle endpoints that depend on the token services
introduced on this branch: activate (ActivationToken), forgotPassword
and resetPassword (PasswordResetToken). Keeping these in a dedicated
interface rather than mixing them into AuthService follows the same
single-responsibility split already in place between AuthService and
the individual token services.
Accounts start disabled by default (@Builder.Default = false).
AuthServiceImpl.register() will set it to false explicitly and
send an activation email; UserServiceImpl.activate() flips it
to true once the token is validated.
Adds the boolean flag that controls whether a registered account
can log in. Default FALSE so every new row starts disabled until
the user clicks the activation link sent by email.
Accounts are now created with enabled=false so they cannot log in
until the user clicks the activation link. register() generates an
ActivationToken and delegates the email to EmailService.
This wires the token infrastructure from this branch into the
registration flow, completing the activation lifecycle.
Passes disabled(!appUser.isEnabled()) to the User builder so Spring
Security's AuthenticationManager rejects login attempts on accounts
that have not yet been activated via the email link.
…d reset

activate() validates the ActivationToken, flips enabled=true, and
returns the updated user profile.
forgotPassword() generates a PasswordResetToken and sends the reset
email only if the email matches a known account (no information
leakage on unknown emails).
resetPassword() validates and consumes the PasswordResetToken, then
re-encodes and saves the new password.
…ints

GET  /auth/activate?token={token}     - activates the account
POST /auth/forgot-password            - sends a reset link
POST /auth/reset-password?token={...} - confirms the new password

The reset token travels as a @RequestParam so the frontend can copy
it straight from the email link URL without embedding it in a JSON
body. ResetPasswordRequest carries the new password and its
confirmation only; @PasswordsMatch validates both fields match and
@validpassword enforces complexity rules.
…word

_10_: activate with valid token returns 200 and user profile.
_11_: forgot-password with valid email returns 200 and delegates to UserService.
_12_: forgot-password with invalid email format returns 400.
_13_: reset-password with valid token and matching passwords returns 200,
      token passed as @RequestParam (not in body).
_14_: reset-password with weak new password returns 400 (@validpassword).
_15_: reset-password with non-matching passwords returns 400 (@PasswordsMatch).

Also adds @MockitoBean UserService so the @WebMvcTest slice can
construct AuthController, which now depends on this interface.
_01_: activate() sets enabled=true and returns user profile.
_02_: forgotPassword() sends reset email when email matches a known account.
_03_: forgotPassword() sends nothing when email is unknown (no info leakage).
_04_: resetPassword() encodes and saves the new password.
…ints

These three endpoints must be accessible without a JWT: activate is
called from an email link (no session), and forgot/reset are used
precisely when the user cannot log in. Without this permit, Spring
Security returns 401 on all three.
register() now creates accounts with enabled=false, so a direct
login attempt after registration fails with DisabledException.
Tests _14_ and _16_ inject AppUserRepository and call enableUser()
between the register and login steps, simulating what the
activation link does in production.
AuthServiceImpl.register() now injects ActivationTokenService and
EmailService. @Injectmocks requires all constructor dependencies to
be present as @mock; without them Mockito leaves them null and the
test fails with NullPointerException on the first call.
Also stubs activationTokenService.generate() and verifies that
sendActivationEmail() is called, and asserts enabled=false on the
saved user.
Without this annotation the full application context loads with the
default profile, which tries to build EmailServiceImpl with a real
JavaMailSender bean. That bean is only declared under the "test"
profile (application-test.yml), causing context load failure.
…swordsMatch into user-management

Merge feature/token-management into feature/user-management, resolving
13 conflicts. Strategy: take HEAD (user-management) as the base for all
files since it is the more evolved branch (JPA Role entity, ManyToMany
roles, accountLocked, full UserService interface); only adopt the
incoming changes where they represent targeted improvements.

Key resolutions:
- ResetPasswordRequest: take token-management version entirely (token
  moved from request body to @RequestParam, @PasswordsMatch added,
  confirmPassword field added, toString() masks both fields).
- AuthController.resetPassword: adopt @RequestParam String token
  signature; remove request.token() call.
- AuthServiceImpl: remove duplicate activationTokenService/emailService
  fields introduced by both sides independently; keep HEAD's register()
  that uses JPA Role entity (.roles(Set.of(userRole))).
- V10 migration dropped: V7 already adds the enabled column on this
  branch; applying V10 would cause a "column already exists" failure.
- AuthControllerTest: clean up duplicate imports, replace _12_/_13_
  (token-in-body form) with @RequestParam versions, add _16_/_17_ for
  @validpassword and @PasswordsMatch coverage on reset-password.
- SecurityAuthorizationTest: take HEAD's inline enableUser logic (no
  helper method needed).
…abledException (403), and ExpiredJwtException (401)

LockedException and DisabledException both extend AuthenticationException, so
they would previously fall into the generic handleAuthentication handler and
return 401. That conflated two distinct conditions: a locked account (should be
423 Locked) and an unactivated account (should be 403 Forbidden - the user is
known but not yet permitted).

Spring resolves @ExceptionHandler by most-specific type first, so placing these
two handlers before the generic AuthenticationException handler is enough.

ExpiredJwtException (io.jsonwebtoken) is not an AuthenticationException - it is
thrown directly when a controller method calls JwtService.extractAllClaims() with
an expired token (e.g. the token-info endpoint). Without a specific handler it
would fall through to the catch-all and return 500.
… 403, and ExpiredJwtException 401 handlers

Each new handler gets its own numbered test (_07_, _08_, _09_) following the
existing convention: throw the exception directly at the handler method and
assert status + message, with no Spring context overhead.
…token support

Adds the jwt.refresh-expiration-ms property binding alongside the existing
jwt.secret and jwt.expiration-ms. JwtService will use this value to set a
longer TTL (7 days) on refresh tokens, keeping it separate from the short-lived
access token expiry.
604800000 ms = 7 days. JwtService will read this value via JwtProperties to
issue refresh tokens with a significantly longer TTL than the 1-hour access
token.
…laims to JwtService

generateRefreshToken(String username) issues a JWT with token_type="refresh" and
the longer refreshExpirationMs TTL from JwtProperties. The token_type claim lets
the application distinguish refresh tokens from access tokens programmatically.

isRefreshToken(String token) verifies the token_type claim, used by
AuthServiceImpl.refresh() (feature/token-management) to reject access tokens
submitted to the refresh endpoint.

extractAllClaims(String token) exposes the full Claims map, used by the new
GET /auth/token-info endpoint so authenticated users can inspect their own JWT.
… methods

JwtProperties record now has a third field (refreshExpirationMs), so all
constructor calls are updated. Added tests:
- _06_: refresh token encodes the subject correctly
- _07_: isRefreshToken() returns true only for a refresh token (access token
  returns false), distinguishing the two by the token_type claim
- _08_: extractAllClaims() returns a map containing the sub claim
…tion

Authenticated users can call GET /api/v1/auth/token-info to inspect the claims
embedded in their current bearer token (sub, iat, exp, and any extra claims such
as roles or jti added by later branches). The endpoint reads the token from the
Authorization header, delegates to JwtService.extractAllClaims(), and wraps the
result in ApiResponse<Map<String,Object>>.

If the token is expired, JwtService.extractAllClaims() throws ExpiredJwtException,
which the new handler in GlobalExceptionHandler now maps to 401 instead of 500.
_09_: authenticated request with a fake token - jwtService.extractAllClaims() is
mocked to return a minimal claims map, asserting that the response contains the
sub and token_type fields.

_10_: unauthenticated request returns 401, confirming the endpoint is behind the
security filter chain.
…token-info into roles-permissions

Conflict resolutions:
- GlobalExceptionHandler: kept HEAD's handlers (InvalidTokenException 400,
  BadCredentialsException 401, AccessDeniedException 403, ObjectOptimisticLocking 409)
  and applied feature/auth changes: LockedException now returns 423, DisabledException
  now returns 403 (was 401 in both cases on this branch), ExpiredJwtException added.
- GlobalExceptionHandlerTest: updated _06_ and _07_ assertions to expect 423 and 403
  respectively; added _12_ for ExpiredJwtException; kept all HEAD-specific tests.
…ice, token-info into token-management

Conflict resolutions:
- JwtService.java: import conflict only - kept both java.util.UUID (HEAD, for jti)
  and java.util.Map (feature/roles-permissions, for extractAllClaims).
- AuthController.java: kept all three fields (authService, userService, jwtService)
  and all endpoints (logout, token-info, activate, forgot-password, reset-password).
  Removed @Operation/@ApiResponses from token-info to match this branch's style.
- JwtServiceTest.java: kept HEAD's _04_ (JwtException on tampered token, since
  isTokenValid was removed in SST adaptation); discarded feature/roles-permissions'
  _04_ (uses isTokenValid) and collapsed _05_ into the existing _04_.
- AuthControllerTest.java: kept all HEAD tests (_08_ to _15_); renumbered token-info
  tests from _09_/_10_ to _16_/_17_ to avoid duplicate method names.
…erarchy

All three existing token entities (ActivationToken, BlacklistedToken,
PasswordResetToken) repeated the same id, user, token, and createdAt
declarations. Extracting them into AuditableToken - a @MappedSuperclass -
removes the duplication and gives every future token type (RefreshToken)
the same baseline fields for free.

Concretely: id (@id + @GeneratedValue), user (@manytoone lazy to AppUser),
token (VARCHAR 768, covers JWTs), createdAt. Each subclass keeps only the
fields that make it specific. @EqualsAndHashCode(of = "id") lives on the
superclass so identity is always PK-based regardless of subclass.
Removes id, user, token, createdAt (now inherited). Switches @builder to
@SuperBuilder so Lombok's builder chain works across the inheritance.
@EqualsAndHashCode delegates to the superclass via callSuper = true.
Removes id, user, token, createdAt (now inherited). Switches @builder to
@SuperBuilder. The token column keeps its VARCHAR 768 length via the
superclass mapping, covering full JWT strings.
Removes id, user, token (now inherited). Adds createdAt via inheritance;
the matching column is added in V11__add_created_at_to_password_reset_tokens.
Keeps expiryDate (renamed from expiresAt at schema creation time) untouched.
… set

user.setRoles(roles) discarded every existing role not listed in the
request. user.getRoles().addAll(roles) preserves roles the user already
has and only adds the new ones. To replace the full set, callers should
call assignRoles for the desired set and use removeRoleFromUser for
individual removals.
…les test

Replaced .roles(Set.of(userRole)) with .build() + getRoles().add(userRole)
so the roles collection is mutable. This is required by the additive
assignRoles fix (B3): calling addAll on an immutable Set.of() would throw
UnsupportedOperationException at test time (Hibernate always provides a
mutable collection at runtime, so production code is unaffected).

Added _04b_ to verify that assignRoles preserves existing roles and only
adds new ones, contrasting with the previous replace-all behavior.
… _15_

LoginRequest.email is annotated @Email, so passing a bare username
("hopper", "babbage") triggered Bean Validation and returned 400 before
the AuthenticationManager was ever reached. The fix passes the full email
address, matching AuthServiceImpl.login() which looks up accounts by email.
…token-management

Conflict in SecurityAuthorizationTest._14_: HEAD already had enableUser()
and "Password1@"; incoming feature/auth only added the email fix
("hopper" -> "hopper@example.com") but kept the old password. Kept HEAD
(email already correct on this branch, password and enableUser preserved).
DisabledException now maps to 403 (added this session). Registering a user
leaves enabled=false, so attempting login on a disabled account throws
DisabledException before the password is ever checked. Added enableUser()
so the authentication reaches BadCredentialsException -> 401 as the test expects.
V11 migration added created_at NOT NULL to password_reset_tokens.
PasswordResetToken now extends AuditableToken which carries that column,
but the repository test built tokens without setting createdAt, triggering
a NOT NULL constraint violation at save time.
Brings in enableUser("babbage") fix for _15_ (DisabledException->403 now
fires before bad-password check on disabled accounts) and createdAt in
PasswordResetTokenRepositoryTest builders (V11 NOT NULL constraint).
No code conflicts: both files auto-merged cleanly.
The merge from feature/token-management brought in enableUser("babbage")
inside _18_ShouldReturn401_WhenPasswordIsWrong, but that helper method
exists only on the token-management version of the file. On
feature/user-management the same pattern is done inline (see _14_, _19_,
_20_). Replaced with the equivalent appUserRepository lambda so the class
compiles.
The setUp() creates appUser with enabled=false (intentional, for _08_
which tests account activation). The forgotPassword() guard added in
item 05 skips disabled accounts, so _09_ never reached the email send.
Fixed by setting enabled=true at the top of _09_ only.
…Response

Renames the JSON field from "token" to "access_token" (OAuth 2.0 naming
convention) and removes the username field. The username is already
embedded as the "sub" claim inside the JWT, so returning it separately
is redundant. The field is renamed accessToken in Java to match, with
an explicit @JsonProperty("access_token") to keep the JSON contract.
Resolves 5 conflicts. AuthResponse on this branch gains the access_token
rename and loses username (from feature/auth) while keeping refreshToken
(introduced here). AuthServiceImpl.login() and refresh() now construct
the 3-field record. All test assertions updated accordingly.
Adds expiresIn (JSON: expires_in) as the access token lifetime in seconds.
The client uses this value to schedule a proactive token refresh before
the access token expires, following the OAuth 2.0 token response convention.
The value is computed by AuthServiceImpl from JwtService.getExpirationMs().
…_in and getExpirationMs()

Resolves 2 conflicts. AuthServiceImpl.login() and refresh() now construct
the 4-field AuthResponse (accessToken, tokenType, refreshToken, expiresIn)
using generateToken(AppUser) from this branch and getExpirationMs()/1000
from feature/token-management. AuthServiceImplTest stubs getExpirationMs()
and asserts the 4-field record. JwtService, AuthControllerTest and
SecurityAuthorizationTest auto-merged cleanly.
Test _20_ (locked account) was still reading .path("data").path("token")
after the AuthResponse field was renamed to accessToken / access_token
(JSON). The token extracted was therefore empty, making the first
authenticated request return 401 instead of 200, failing the assertion
at line 241. Same fix already applied to _16_ on feature/token-management.
feat(user-management): refresh token system, security hardening, and role management
@EdgarEldy
EdgarEldy merged commit 1cbb1f1 into master Nov 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant