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