feat(auth): bootstrap admin on first boot + forced password change + admin reset
CI / Build Native (push) Has been cancelled
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user