Files
shot-crafter-calculator/src/main/java/com/l2/shots/auth/AuthResource.java
T
darroyo 017c58a2b2
CI / Build Native (push) Successful in 6m23s
refactor(dtos): convert all DTOs to Java records for native-mode Jackson
En Quarkus native-image, Jackson no introspecta public fields de clases
con normales (deja de refleccionar field metadata). Salia error:

  No serializer found for class com.l2.shots.auth.AuthMeResponse and
  no properties discovered to create BeanSerializer

Los records tienen auto-accessors (id(), username(), etc) que Jackson
serializa nativamente, sin necesidad de reflection o
@RegisterForReflection.

Convertidos:
- auth: AuthMeResponse, AdminUserSummary, Credentials,
         ChangePasswordRequest, AdminResetPasswordRequest, ErrorBody,
         MustChangeBody
- history: RunSummary, RunDetails, RunItem, RunSnapshot, RunIn,
           HistoryStats
- state: AppState (con Insumos y FormulaDto anidados)

Callers actualizados para usar accessors en vez de field access:
- AuthService.listUsersForAdmin -> AdminUserSummary.from
- AuthService.saveRun -> RunSummary.from / RunDetails.of
- AuthResource.register/login -> creds.username() / creds.password()
- HistoryResource.saveRun -> input.items() etc

Notas:
- RunDetails NO puede extender RunSummary en records (JLS no permite
  extends entre records). Va como record independiente con todos los
  campos. El JSON que produce matchea la interface RunDetails del
  frontend (que extendia RunSummary).
- HistoryStats ahora se construye de una sola vez al final de
  computeStats en lugar de ir mutando campos.
- MustChangeBody quedo con un constructor no-canonico no-arg para
  mantener el call site original (new MustChangeBody()).
2026-08-15 00:46:19 -04:00

186 lines
6.3 KiB
Java

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;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
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)
@Consumes(MediaType.APPLICATION_JSON)
public class AuthResource {
@Inject
AuthService authService;
@Inject
JwtCookieAuth jwtCookieAuth;
@Inject
JsonWebToken currentJwt;
@ConfigProperty(name = "app.auth.cookie-name")
String cookieName;
@ConfigProperty(name = "app.auth.cookie-max-age-seconds")
int cookieMaxAge;
@POST
@Path("/register")
public Response register(Credentials creds) {
Optional<User> result = authService.register(creds.username(), creds.password());
if (result.isEmpty()) {
return Response.status(409)
.entity(new ErrorBody("username no disponible o datos inválidos"))
.build();
}
User user = result.get();
String token = authService.buildToken(user.id);
return Response.ok(AuthService.toAuthMe(user))
.cookie(buildAuthCookie(token))
.build();
}
@POST
@Path("/login")
public Response login(Credentials creds) {
Optional<User> result = authService.authenticate(creds.username(), creds.password());
if (result.isEmpty()) {
return Response.status(401)
.entity(new ErrorBody("credenciales inválidas"))
.build();
}
User user = result.get();
String token = authService.buildToken(user.id);
return Response.ok(AuthService.toAuthMe(user))
.cookie(buildAuthCookie(token))
.build();
}
@POST
@Path("/logout")
public Response logout() {
return Response.noContent()
.cookie(clearAuthCookie())
.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) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) {
return Response.status(401).build();
}
return authService.getUserFromToken(jwt.get())
.map(u -> Response.ok(u).build())
.orElse(Response.status(401).build());
}
@GET
@Path("/admin/users")
@RolesAllowed("admin")
public Response adminListUsers() {
return Response.ok(authService.listUsersForAdmin()).build();
}
@GET
@Path("/check")
@Authenticated
public Response check() {
return Response.ok().build();
}
private NewCookie buildAuthCookie(String token) {
return new NewCookie.Builder(cookieName)
.value(token)
.path("/")
.httpOnly(true)
.secure(false)
.sameSite(NewCookie.SameSite.LAX)
.maxAge(cookieMaxAge)
.build();
}
private NewCookie clearAuthCookie() {
return new NewCookie.Builder(cookieName)
.value("")
.path("/")
.httpOnly(true)
.secure(false)
.sameSite(NewCookie.SameSite.LAX)
.maxAge(0)
.build();
}
public record ErrorBody(String error) {
}
}