/* * 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
* 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
* 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
* 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
* 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("
* 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();
}
}