feat(auth): bootstrap admin on first boot + forced password change + admin reset
CI / Build Native (push) Has been cancelled

Backend changes (no frontend yet):

Schema (User entity)
- + mustChange_password (boolean NOT NULL, default false)
- + is_admin (boolean NOT NULL, default false)
Hibernate update mode adds both columns automatically.

BootstrapAdmin (new, ApplicationScoped, @Observes StartupEvent)
- runs only when User.count() == 0 and app.bootstrap.admin.enabled=true
- generates a 20-char random password (alphabet without 0/o/O/1/l/I)
- persists the user with isAdmin=true, mustChangePassword=true
- prints a banner to stdout AND to the JBoss logger so docker logs
  picks it up:
    BOOTSTRAP-ADMIN-USERNAME admin
    BOOTSTRAP-ADMIN-PASSWORD <random>
    BOOTSTRAP-ADMIN-CHANGE   This password MUST be changed on first login ...
- idempotent: skips if any user already exists

MustChangePasswordFilter (new, @Provider ContainerRequestFilter)
- runs after JWT auth (Priorities.AUTHENTICATION + 100)
- for authenticated requests with mustChangePassword=true, returns
  403 with {error, mustChangePassword:true} unless the path is
  /api/auth/change-password or /api/auth/logout

Change-password endpoint (POST /api/auth/change-password)
- @Authenticated, body {currentPassword, newPassword}
- verifies currentPassword via bcrypt, validates newPassword>=8 chars,
  updates hash and sets mustChangePassword=false
- returns updated AuthMeResponse and re-issues the auth cookie

Admin reset endpoint (POST /api/auth/admin/reset-password)
- @RolesAllowed("admin")
- body {username, newPassword}
- sets target's passwordHash and mustChangePassword=true (forces change
  on next login)
- security: only users in the JWT 'admin' group can hit it; isAdmin
  is stored on the user record so a stale token can't promote itself

JWT groups now include 'admin' for isAdmin users; previously everyone
was just 'user'.

Config (application.properties)
- app.bootstrap.admin.enabled=true
- app.bootstrap.admin.username=admin
This commit is contained in:
2026-08-14 20:04:44 -04:00
parent fb021b165b
commit 1d6fc08a25
9 changed files with 287 additions and 6 deletions
@@ -1,6 +1,7 @@
package com.l2.shots.auth;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
@@ -16,6 +17,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.Optional;
import java.util.UUID;
@Path("/api/auth")
@Produces(MediaType.APPLICATION_JSON)
@@ -28,6 +30,9 @@ public class AuthResource {
@Inject
JwtCookieAuth jwtCookieAuth;
@Inject
JsonWebToken currentJwt;
@ConfigProperty(name = "app.auth.cookie-name")
String cookieName;
@@ -74,6 +79,59 @@ public class AuthResource {
.build();
}
@POST
@Path("/change-password")
@Authenticated
public Response changePassword(@Context HttpHeaders headers, ChangePasswordRequest body) {
if (body == null || body.currentPassword == null || body.newPassword == null) {
return Response.status(400).entity(new ErrorBody("Faltan campos requeridos")).build();
}
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) return Response.status(401).build();
Optional<User> user = authService.getEntityFromToken(jwt.get());
if (user.isEmpty()) return Response.status(401).build();
AuthService.ChangePasswordResult result = authService.changePassword(
user.get().id, body.currentPassword, body.newPassword);
switch (result) {
case OK:
User refreshed = User.findById(user.get().id);
if (refreshed == null) return Response.status(401).build();
String newToken = authService.buildToken(refreshed.id);
return Response.ok(AuthService.toAuthMe(refreshed))
.cookie(buildAuthCookie(newToken))
.build();
case WRONG_CURRENT_PASSWORD:
return Response.status(401).entity(new ErrorBody("La contraseña actual es incorrecta")).build();
case WEAK_NEW_PASSWORD:
return Response.status(400).entity(new ErrorBody(
"La nueva contraseña debe tener al menos " + AuthService.MIN_PASSWORD_LENGTH + " caracteres")).build();
case NOT_FOUND:
return Response.status(401).build();
default:
return Response.status(500).build();
}
}
@POST
@Path("/admin/reset-password")
@RolesAllowed("admin")
public Response adminResetPassword(AdminResetPasswordRequest body) {
if (body == null || body.username == null || body.newPassword == null) {
return Response.status(400).entity(new ErrorBody("Faltan campos requeridos")).build();
}
UUID requesterId;
try {
requesterId = UUID.fromString(currentJwt.getSubject());
} catch (Exception e) {
return Response.status(401).build();
}
boolean ok = authService.resetPasswordAsAdmin(requesterId, body.username, body.newPassword);
if (!ok) {
return Response.status(400).entity(new ErrorBody("Usuario no encontrado o contraseña inválida")).build();
}
return Response.ok().build();
}
@GET
@Path("/me")
public Response me(@Context HttpHeaders headers) {