From 1d6fc08a25aa2e49e5e0b727a0dae472e59757df Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Fri, 14 Aug 2026 20:04:44 -0400 Subject: [PATCH] feat(auth): bootstrap admin on first boot + forced password change + admin reset 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 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 --- .../shots/auth/AdminResetPasswordRequest.java | 6 ++ .../com/l2/shots/auth/AuthMeResponse.java | 6 +- .../java/com/l2/shots/auth/AuthResource.java | 58 +++++++++++++++ .../java/com/l2/shots/auth/AuthService.java | 70 +++++++++++++++++-- .../com/l2/shots/auth/BootstrapAdmin.java | 70 +++++++++++++++++++ .../l2/shots/auth/ChangePasswordRequest.java | 6 ++ .../shots/auth/MustChangePasswordFilter.java | 67 ++++++++++++++++++ src/main/java/com/l2/shots/auth/User.java | 6 ++ src/main/resources/application.properties | 4 ++ 9 files changed, 287 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/l2/shots/auth/AdminResetPasswordRequest.java create mode 100644 src/main/java/com/l2/shots/auth/BootstrapAdmin.java create mode 100644 src/main/java/com/l2/shots/auth/ChangePasswordRequest.java create mode 100644 src/main/java/com/l2/shots/auth/MustChangePasswordFilter.java diff --git a/src/main/java/com/l2/shots/auth/AdminResetPasswordRequest.java b/src/main/java/com/l2/shots/auth/AdminResetPasswordRequest.java new file mode 100644 index 0000000..789b71b --- /dev/null +++ b/src/main/java/com/l2/shots/auth/AdminResetPasswordRequest.java @@ -0,0 +1,6 @@ +package com.l2.shots.auth; + +public class AdminResetPasswordRequest { + public String username; + public String newPassword; +} diff --git a/src/main/java/com/l2/shots/auth/AuthMeResponse.java b/src/main/java/com/l2/shots/auth/AuthMeResponse.java index 51b34c8..2c925bb 100644 --- a/src/main/java/com/l2/shots/auth/AuthMeResponse.java +++ b/src/main/java/com/l2/shots/auth/AuthMeResponse.java @@ -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; } } diff --git a/src/main/java/com/l2/shots/auth/AuthResource.java b/src/main/java/com/l2/shots/auth/AuthResource.java index 1262bf0..1442b6b 100644 --- a/src/main/java/com/l2/shots/auth/AuthResource.java +++ b/src/main/java/com/l2/shots/auth/AuthResource.java @@ -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 jwt = jwtCookieAuth.extractToken(headers); + if (jwt.isEmpty()) return Response.status(401).build(); + Optional 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) { diff --git a/src/main/java/com/l2/shots/auth/AuthService.java b/src/main/java/com/l2/shots/auth/AuthService.java index 26d90c6..93cf275 100644 --- a/src/main/java/com/l2/shots/auth/AuthService.java +++ b/src/main/java/com/l2/shots/auth/AuthService.java @@ -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 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 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 getUserFromToken(JsonWebToken jwt) { + return getEntityFromToken(jwt).map(u -> new AuthMeResponse(u.id, u.username, u.createdAt, u.mustChangePassword, u.isAdmin)); + } + + public Optional 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); } } diff --git a/src/main/java/com/l2/shots/auth/BootstrapAdmin.java b/src/main/java/com/l2/shots/auth/BootstrapAdmin.java new file mode 100644 index 0000000..92aa9fc --- /dev/null +++ b/src/main/java/com/l2/shots/auth/BootstrapAdmin.java @@ -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(); + } +} diff --git a/src/main/java/com/l2/shots/auth/ChangePasswordRequest.java b/src/main/java/com/l2/shots/auth/ChangePasswordRequest.java new file mode 100644 index 0000000..cc66d9b --- /dev/null +++ b/src/main/java/com/l2/shots/auth/ChangePasswordRequest.java @@ -0,0 +1,6 @@ +package com.l2.shots.auth; + +public class ChangePasswordRequest { + public String currentPassword; + public String newPassword; +} diff --git a/src/main/java/com/l2/shots/auth/MustChangePasswordFilter.java b/src/main/java/com/l2/shots/auth/MustChangePasswordFilter.java new file mode 100644 index 0000000..d2dfcf1 --- /dev/null +++ b/src/main/java/com/l2/shots/auth/MustChangePasswordFilter.java @@ -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 jwtOpt; + try { + jwtOpt = jwtCookieAuth.extractToken(headers); + } catch (Exception e) { + return; + } + if (jwtOpt.isEmpty() || jwtOpt.get() == null) return; + + Optional 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; + } +} diff --git a/src/main/java/com/l2/shots/auth/User.java b/src/main/java/com/l2/shots/auth/User.java index d0edc29..4c24331 100644 --- a/src/main/java/com/l2/shots/auth/User.java +++ b/src/main/java/com/l2/shots/auth/User.java @@ -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(); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b0629b7..6ec1aa6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -23,4 +23,8 @@ app.auth.cookie-max-age-seconds=86400 # Security quarkus.http.auth.proactive=false +# Bootstrap admin (default user created on first boot of an empty DB) +app.bootstrap.admin.enabled=true +app.bootstrap.admin.username=admin + %native.quarkus.native.resources.includes=META-INF/resources/.*,publicKey.pem,privateKey.pem