/* * Copyright @ 2013 CurisTEC, S.A.S. All Rights Reserved. */ package net.curisit.securis.services; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceException; import jakarta.persistence.TypedQuery; import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import net.curisit.integrity.commons.Utils; import net.curisit.securis.DefaultExceptionHandler; import net.curisit.securis.SeCurisException; import net.curisit.securis.db.Application; import net.curisit.securis.db.Organization; import net.curisit.securis.db.User; import net.curisit.securis.db.User.Rol; import net.curisit.securis.ioc.EnsureTransaction; import net.curisit.securis.security.BasicSecurityContext; import net.curisit.securis.security.Securable; import net.curisit.securis.services.exception.SeCurisServiceException; import net.curisit.securis.services.exception.SeCurisServiceException.ErrorCodes; import net.curisit.securis.utils.CacheTTL; import net.curisit.securis.utils.TokenHelper; /** * UserResource *

* REST resource that manages users (CRUD + authentication helpers). * All endpoints are guarded and ADMIN-only unless otherwise stated. *

* Notes: * - Uses {@link BasicSecurityContext} authorization via @Securable and @RolesAllowed. * - Uses JPA {@link EntityManager} injected through @Context. * - Mutating endpoints are wrapped in @EnsureTransaction to guarantee commit/rollback. * - Passwords are stored as SHA-256 hashes (see {@link Utils#sha256(String)}). * * Endpoints: * GET /user/ -> list users * GET /user/{uid} -> get user by username * POST /user/ -> create user (idempotent: upsert semantics) * PUT /user/{uid} -> update user (creates if not exists) * POST /user/login -> password authentication; returns token and basic identity * POST /user/check -> validates a token and returns token metadata * GET /user/logout -> invalidates HTTP session (non-token based) * * Thread-safety: RequestScoped. No shared mutable state. * * @author roberto * Last reviewed by JRA on Oct 5, 2025. */ @Path("/user") @RequestScoped public class UserResource { /** Token encoder/decoder & validator. */ @Inject TokenHelper tokenHelper; /** Small cache to invalidate role/org derived data after user mutations. */ @Inject private CacheTTL cache; /** JPA entity manager bound to the current request context. */ @Context EntityManager em; private static final Logger LOG = LogManager.getLogger(UserResource.class); /** * UserResource * Default constructor for CDI. */ public UserResource() { } // --------------------------------------------------------------------- // Read operations // --------------------------------------------------------------------- /** * index *

* List all users. * * Security: ADMIN only. * * @return 200 OK with JSON array of {@link User}, or 200 OK with empty list. */ @GET @Path("/") @Produces({ MediaType.APPLICATION_JSON }) @Securable(roles = Rol.ADMIN) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) public Response index() { LOG.info("Getting users list "); em.clear(); TypedQuery q = em.createNamedQuery("list-users", User.class); List list = q.getResultList(); return Response.ok(list).build(); } /** * get *

* Retrieve a single user by username. * * Security: ADMIN only. * * @param uid Username (primary key). * @param token Optional token header (unused here, enforced by filters). * @return 200 OK with user payload or 404 if not found/invalid uid. */ @GET @Path("/{uid}") @Produces({ MediaType.APPLICATION_JSON }) @Securable(roles = Rol.ADMIN) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) public Response get(@PathParam("uid") String uid, @HeaderParam(TokenHelper.TOKEN_HEADER_PÀRAM) String token) { LOG.info("Getting user data for id: {}: ", uid); if (uid == null || "".equals(uid)) { LOG.error("User ID is mandatory"); return Response.status(Status.NOT_FOUND).build(); } em.clear(); User lt = em.find(User.class, uid); if (lt == null) { LOG.error("User with id {} not found in DB", uid); return Response.status(Status.NOT_FOUND).build(); } return Response.ok(lt).build(); } // --------------------------------------------------------------------- // Create / Update / Delete // --------------------------------------------------------------------- /** * create *

* Create a new user. If the username already exists, delegates to {@link #modify(User, String, String)} * to behave like an upsert. * * Security: ADMIN only. * Transaction: yes (via @EnsureTransaction). * * @param user Incoming user payload. Password must be non-empty (plain text). * Password is SHA-256 hashed before persist. * @param token Security token header (unused here; enforced by filters). * @return 200 OK with created/updated user; 4xx on validation errors. */ @POST @Path("/") @Consumes(MediaType.APPLICATION_JSON) @Produces({ MediaType.APPLICATION_JSON }) @EnsureTransaction @Securable(roles = Rol.ADMIN) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) public Response create(User user, @HeaderParam(TokenHelper.TOKEN_HEADER_PÀRAM) String token) { LOG.info("Creating new user"); User currentUser = em.find(User.class, user.getUsername()); if (currentUser != null) { LOG.info("User with id {} was found in DB, we'll try to modify it", user.getUsername()); return modify(user, user.getUsername(), token); } try { this.setUserOrgs(user, user.getOrgsIds(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } try { this.setUserApps(user, user.getAppsIds(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } // Password must be provided on create if (user.getPassword() != null && !"".equals(user.getPassword())) { user.setPassword(Utils.sha256(user.getPassword())); } else { return Response.status(DefaultExceptionHandler.DEFAULT_APP_ERROR_STATUS_CODE) .header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, "User password is mandatory") .build(); } user.setModificationTimestamp(new Date()); user.setLastLogin(null); user.setCreationTimestamp(new Date()); em.persist(user); return Response.ok(user).build(); } /** * setUserOrgs *

* Resolve and set the organizations for a user from a set of IDs. * Validates each id exists in DB. * * @param user Target user entity. * @param orgsIds Organization ids to assign (nullable/empty allowed). * @param em EntityManager. * @throws SeCurisException if any of the referenced organizations does not exist. */ private void setUserOrgs(User user, Set orgsIds, EntityManager em) throws SeCurisException { Set orgs = null; if (orgsIds != null && !orgsIds.isEmpty()) { orgs = new HashSet<>(); for (Integer orgId : orgsIds) { Organization o = em.find(Organization.class, orgId); if (o == null) { LOG.error("User organization with id {} not found in DB", orgId); throw new SeCurisException("User's organization not found with ID: " + orgId); } orgs.add(o); } } user.setOrganizations(orgs); } /** * setUserApps *

* Resolve and set the applications for a user from a set of IDs. * Validates each id exists in DB. * * @param user Target user entity. * @param appsIds Application ids to assign (nullable/empty allowed). * @param em EntityManager. * @throws SeCurisException if any of the referenced applications does not exist. */ private void setUserApps(User user, Set appsIds, EntityManager em) throws SeCurisException { Set apps = null; if (appsIds != null && !appsIds.isEmpty()) { apps = new HashSet<>(); for (Integer appId : appsIds) { Application o = em.find(Application.class, appId); if (o == null) { LOG.error("User application with id {} not found in DB", appId); throw new SeCurisException("User's application not found with ID: " + appId); } apps.add(o); } } user.setApplications(apps); } /** * modify *

* Update an existing user. If the user does not exist, delegates to {@link #create(User, String)}. * Password is updated only if a non-empty password is provided. * Organizations & applications are fully replaced with the given ids. * * Security: ADMIN only. * Transaction: yes (via @EnsureTransaction). * * @param user Incoming user payload. * @param uid Username (path param) to update. * @param token Security token header (unused here). * @return 200 OK with updated user; 404 if reference entities are missing. */ @PUT @POST @Path("/{uid}") @EnsureTransaction @Consumes(MediaType.APPLICATION_JSON) @Produces({ MediaType.APPLICATION_JSON }) @Securable(roles = Rol.ADMIN) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) public Response modify(User user, @PathParam("uid") String uid, @HeaderParam(TokenHelper.TOKEN_HEADER_PÀRAM) String token) { LOG.info("Modifying user with id: {}", uid); User currentUser = em.find(User.class, uid); if (currentUser == null) { LOG.info("User with id {} not found in DB, we'll try to create it", uid); return create(user, token); } try { this.setUserOrgs(currentUser, user.getOrgsIds(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } try { this.setUserApps(currentUser, user.getAppsIds(), em); } catch (SeCurisException e) { return Response.status(Status.NOT_FOUND).header(DefaultExceptionHandler.ERROR_MESSAGE_HEADER, e.getMessage()).build(); } currentUser.setFirstName(user.getFirstName()); currentUser.setLastName(user.getLastName()); currentUser.setRoles(user.getRoles()); currentUser.setLang(user.getLang()); currentUser.setModificationTimestamp(new Date()); // Optional password update if (user.getPassword() != null && !"".equals(user.getPassword())) { currentUser.setPassword(Utils.sha256(user.getPassword())); } // lastLogin can be set through API (rare), otherwise managed at login currentUser.setLastLogin(user.getLastLogin()); em.persist(currentUser); clearUserCache(currentUser.getUsername()); return Response.ok(currentUser).build(); } /** * delete *

* Delete a user by username. * * Security: ADMIN only. * Transaction: yes (via @EnsureTransaction). * * @param uid Username to delete. * @param request Http servlet request (unused). * @return 200 OK on success; 404 if user does not exist. */ @DELETE @Path("/{uid}") @EnsureTransaction @Produces({ MediaType.APPLICATION_JSON }) @Securable(roles = Rol.ADMIN) @RolesAllowed(BasicSecurityContext.ROL_ADMIN) public Response delete(@PathParam("uid") String uid, @Context HttpServletRequest request) { LOG.info("Deleting app with id: {}", uid); User user = em.find(User.class, uid); if (user == null) { LOG.error("User with id {} can not be deleted, It was not found in DB", uid); return Response.status(Status.NOT_FOUND).build(); } em.remove(user); clearUserCache(user.getUsername()); return Response.ok(Utils.createMap("success", true, "id", uid)).build(); } /** * clearUserCache *

* Helper to invalidate cached role/org projections after changes. * * @param username The user whose cache entries must be cleared. */ private void clearUserCache(String username) { cache.remove("roles_" + username); cache.remove("orgs_" + username); } // --------------------------------------------------------------------- // Auth helpers // --------------------------------------------------------------------- /** * login *

* Validates username & password against stored SHA-256 hash. On success, * updates lastLogin timestamp, clears cache and returns an auth token. * * Token format: Base64(" ") * where secret = SHA-256(seed + user + date). * * @param username Plain username. * @param password Plain password (SHA-256 will be computed server-side). * @param request Http request, used to log underlying session (not required for token flow). * @return 200 OK with {token, username, full_name}; 401 on invalid credentials. * @throws SeCurisServiceException if user is missing or password mismatch. */ @POST @Path("/login") @Produces({ MediaType.APPLICATION_JSON }) public Response login(@FormParam("username") String username, @FormParam("password") String password, @Context HttpServletRequest request) throws SeCurisServiceException { LOG.info("index session: " + request.getSession()); User user = em.find(User.class, username); if (user == null) { LOG.error("Unknown username {} used in login service", username); throw new SeCurisServiceException(ErrorCodes.UNAUTHORIZED_ACCESS, "Wrong credentials"); } String securedPassword = Utils.sha256(password); if (securedPassword == null || !securedPassword.equals(user.getPassword())) { throw new SeCurisServiceException(ErrorCodes.UNAUTHORIZED_ACCESS, "Wrong credentials"); } user.setLastLogin(new Date()); em.getTransaction().begin(); try { em.persist(user); em.getTransaction().commit(); } catch (PersistenceException ex) { LOG.error("Error updating last login date for user: {}", username); LOG.error(ex); em.getTransaction().rollback(); } clearUserCache(username); String userFullName = String.format("%s %s", user.getFirstName(), user.getLastName() == null ? "" : user.getLastName()).trim(); String tokenAuth = tokenHelper.generateToken(username); return Response.ok(Utils.createMap("success", true, "token", tokenAuth, "username", username, "full_name", userFullName)).build(); } /** * check *

* Validates a token and echoes token claims (user, creation date, token string). * Accepts header or query param for convenience. * * @param token Token in header {@link TokenHelper#TOKEN_HEADER_PÀRAM}, may be null. * @param token2 Token in query param 'token', used if header is null. * @return 200 OK with {valid, user, date, token} or 403 if token missing. */ @POST @Path("/check") @Produces({ MediaType.APPLICATION_JSON }) public Response check(@HeaderParam(TokenHelper.TOKEN_HEADER_PÀRAM) String token, @QueryParam("token") String token2) { if (token == null) { token = token2; } if (token == null) { return Response.status(Status.FORBIDDEN).build(); } LOG.info("Token : " + token); String user = tokenHelper.extractUserFromToken(token); LOG.info("Token user: " + user); Date date = tokenHelper.extractDateCreationFromToken(token); LOG.info("Token date: " + date); boolean valid = tokenHelper.isTokenValid(token); LOG.info("Is Token valid: " + valid); return Response.ok(Utils.createMap("valid", true, "user", user, "date", date, "token", token)).build(); } /** * logout *

* Invalidates the HTTP session (useful if the UI also tracks session). * Note: token-based auth is stateless; tokens are not revoked here. * * @param request HttpServletRequest to invalidate session. * @return 200 OK always. */ @GET @Path("/logout") @Produces({ MediaType.APPLICATION_JSON }) public Response logout(@Context HttpServletRequest request) { request.getSession().invalidate(); return Response.ok().build(); } }