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
@@ -0,0 +1,6 @@
package com.l2.shots.auth;
public class AdminResetPasswordRequest {
public String username;
public String newPassword;
}
@@ -7,12 +7,16 @@ public class AuthMeResponse {
public UUID id;
public String username;
public Instant createdAt;
public boolean mustChangePassword;
public boolean isAdmin;
public AuthMeResponse() {}
public AuthMeResponse(UUID id, String username, Instant createdAt) {
public AuthMeResponse(UUID id, String username, Instant createdAt, boolean mustChangePassword, boolean isAdmin) {
this.id = id;
this.username = username;
this.createdAt = createdAt;
this.mustChangePassword = mustChangePassword;
this.isAdmin = isAdmin;
}
}
@@ -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) {
@@ -9,6 +9,7 @@ import org.eclipse.microprofile.jwt.JsonWebToken;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -20,6 +21,13 @@ public class AuthService {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{3,30}$");
public static final int MIN_PASSWORD_LENGTH = 8;
public enum ChangePasswordResult {
OK,
NOT_FOUND,
WRONG_CURRENT_PASSWORD,
WEAK_NEW_PASSWORD
}
@ConfigProperty(name = "mp.jwt.verify.issuer")
String issuer;
@@ -36,11 +44,26 @@ public class AuthService {
user.username = username.toLowerCase();
user.passwordHash = BcryptUtil.bcryptHash(password);
user.createdAt = Instant.now();
user.mustChangePassword = false;
user.isAdmin = false;
user.persist();
return Optional.of(user);
}
@Transactional
public User registerInternal(String username, String password, boolean isAdmin, boolean mustChangePassword) {
User user = new User();
user.id = UUID.randomUUID();
user.username = username.toLowerCase();
user.passwordHash = BcryptUtil.bcryptHash(password);
user.createdAt = Instant.now();
user.mustChangePassword = mustChangePassword;
user.isAdmin = isAdmin;
user.persist();
return user;
}
public Optional<User> authenticate(String username, String password) {
if (username == null || password == null) return Optional.empty();
User user = User.findByUsernameCaseInsensitive(username.trim());
@@ -50,26 +73,63 @@ public class AuthService {
}
public String buildToken(UUID userId) {
User user = User.findById(userId);
if (user == null) throw new IllegalStateException("user not found: " + userId);
Set<String> groups = new HashSet<>();
groups.add("user");
if (user.isAdmin) groups.add("admin");
return Jwt.issuer(issuer)
.subject(userId.toString())
.groups(Set.of("user"))
.groups(groups)
.expiresIn(Duration.ofSeconds(86400))
.sign();
}
@Transactional
public ChangePasswordResult changePassword(UUID userId, String currentPassword, String newPassword) {
User user = User.findById(userId);
if (user == null) return ChangePasswordResult.NOT_FOUND;
if (currentPassword == null || !BcryptUtil.matches(currentPassword, user.passwordHash)) {
return ChangePasswordResult.WRONG_CURRENT_PASSWORD;
}
if (newPassword == null || newPassword.length() < MIN_PASSWORD_LENGTH) {
return ChangePasswordResult.WEAK_NEW_PASSWORD;
}
user.passwordHash = BcryptUtil.bcryptHash(newPassword);
user.mustChangePassword = false;
user.persist();
return ChangePasswordResult.OK;
}
@Transactional
public boolean resetPasswordAsAdmin(UUID requesterId, String targetUsername, String newPassword) {
User requester = User.findById(requesterId);
if (requester == null || !requester.isAdmin) return false;
if (targetUsername == null || newPassword == null) return false;
if (newPassword.length() < MIN_PASSWORD_LENGTH) return false;
User target = User.findByUsernameCaseInsensitive(targetUsername.trim());
if (target == null) return false;
target.passwordHash = BcryptUtil.bcryptHash(newPassword);
target.mustChangePassword = true;
target.persist();
return true;
}
public Optional<AuthMeResponse> getUserFromToken(JsonWebToken jwt) {
return getEntityFromToken(jwt).map(u -> new AuthMeResponse(u.id, u.username, u.createdAt, u.mustChangePassword, u.isAdmin));
}
public Optional<User> getEntityFromToken(JsonWebToken jwt) {
if (jwt == null || jwt.getSubject() == null) return Optional.empty();
try {
UUID userId = UUID.fromString(jwt.getSubject());
User user = User.findById(userId);
if (user == null) return Optional.empty();
return Optional.of(new AuthMeResponse(user.id, user.username, user.createdAt));
return Optional.ofNullable(User.findById(userId));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
public static AuthMeResponse toAuthMe(User user) {
return new AuthMeResponse(user.id, user.username, user.createdAt);
return new AuthMeResponse(user.id, user.username, user.createdAt, user.mustChangePassword, user.isAdmin);
}
}
@@ -0,0 +1,70 @@
package com.l2.shots.auth;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.security.SecureRandom;
/**
* Crea un usuario admin por defecto si la base de datos está vacía,
* e imprime la contraseña temporal a stdout para que el operador la lea
* una sola vez desde el log de Docker.
*
* Idempotente: si ya existe algún user, no hace nada.
* Desactivable con app.bootstrap.admin.enabled=false (env: APP_BOOTSTRAP_ADMIN_ENABLED).
*/
@ApplicationScoped
public class BootstrapAdmin {
private static final Logger LOG = Logger.getLogger(BootstrapAdmin.class);
private static final SecureRandom RANDOM = new SecureRandom();
// Alfabeto sin chars ambiguos (0/o/O, 1/l/I) para no cansar al tipear.
private static final char[] ALPHABET =
"ABCDEFGHJKLMNPQRSTUVWXYZ23456789abcdefghjkmnpqrstuvwxyz".toCharArray();
private static final int PASSWORD_LENGTH = 20;
@Inject
AuthService authService;
@ConfigProperty(name = "app.bootstrap.admin.enabled", defaultValue = "true")
boolean enabled;
@ConfigProperty(name = "app.bootstrap.admin.username", defaultValue = "admin")
String username;
void onStart(@Observes StartupEvent ev) {
if (!enabled) {
LOG.info("Bootstrap admin disabled (app.bootstrap.admin.enabled=false)");
return;
}
long count = User.count();
if (count > 0) {
LOG.infof("Bootstrap admin skipped: %d users already exist", count);
return;
}
String tempPassword = generatePassword();
authService.registerInternal(username, tempPassword, true, true);
String banner = "\n"
+ "BOOTSTRAP-ADMIN-USERNAME " + username + "\n"
+ "BOOTSTRAP-ADMIN-PASSWORD " + tempPassword + "\n"
+ "BOOTSTRAP-ADMIN-CHANGE This password MUST be changed on first login via POST /api/auth/change-password\n";
LOG.warn(banner);
// Backup a stdout por si algún pipeline colecta stdout pero no el logger JBoss.
System.out.print(banner);
System.out.flush();
}
private static String generatePassword() {
StringBuilder sb = new StringBuilder(PASSWORD_LENGTH);
for (int i = 0; i < PASSWORD_LENGTH; i++) {
sb.append(ALPHABET[RANDOM.nextInt(ALPHABET.length)]);
}
return sb.toString();
}
}
@@ -0,0 +1,6 @@
package com.l2.shots.auth;
public class ChangePasswordRequest {
public String currentPassword;
public String newPassword;
}
@@ -0,0 +1,67 @@
package com.l2.shots.auth;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.Optional;
/**
* Bloquea todo request autenticado para usuarios con mustChangePassword=true,
* salvo /change-password y /logout (para que pueda salir si metió mal la clave).
*
* Corre DESPUÉS de la auth para que el JWT ya esté parseado.
*/
@Provider
@Priority(Priorities.AUTHENTICATION + 100)
public class MustChangePasswordFilter implements ContainerRequestFilter {
@Inject
AuthService authService;
@Inject
JwtCookieAuth jwtCookieAuth;
@Context
HttpHeaders headers;
@Override
public void filter(ContainerRequestContext ctx) {
Optional<JsonWebToken> jwtOpt;
try {
jwtOpt = jwtCookieAuth.extractToken(headers);
} catch (Exception e) {
return;
}
if (jwtOpt.isEmpty() || jwtOpt.get() == null) return;
Optional<User> userOpt = authService.getEntityFromToken(jwtOpt.get());
if (userOpt.isEmpty()) return;
User user = userOpt.get();
if (!user.mustChangePassword) return;
String path = ctx.getUriInfo().getPath();
if (path.startsWith("/")) path = path.substring(1);
if (path.startsWith("api/auth/change-password")) return;
if (path.startsWith("api/auth/logout")) return;
ctx.abortWith(Response.status(403)
.entity(new MustChangeBody())
.type(MediaType.APPLICATION_JSON)
.build());
}
public static class MustChangeBody {
public String error = "Debe cambiar la contraseña antes de continuar";
public boolean mustChangePassword = true;
}
}
@@ -25,6 +25,12 @@ public class User extends PanacheEntityBase {
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(name = "must_change_password", nullable = false)
public boolean mustChangePassword = false;
@Column(name = "is_admin", nullable = false)
public boolean isAdmin = false;
public static User findByUsername(String username) {
return find("username", username.toLowerCase()).firstResult();
}