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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user