/* * Copyright @ 2013 CurisTEC, S.A.S. All Rights Reserved. */ package net.curisit.securis.services; import java.io.IOException; import java.util.Date; import java.util.List; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; 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.MediaType; import jakarta.ws.rs.core.Response; import org.apache.commons.lang3.time.DateUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput; import net.curisit.securis.LicenseGenerator; import net.curisit.securis.LicenseManager; import net.curisit.securis.SeCurisException; import net.curisit.securis.beans.LicenseBean; import net.curisit.securis.beans.RequestBean; import net.curisit.securis.beans.SignedLicenseBean; import net.curisit.securis.beans.StatusBean; import net.curisit.securis.db.BlockedRequest; import net.curisit.securis.db.License; import net.curisit.securis.db.LicenseHistory; import net.curisit.securis.db.LicenseStatus; import net.curisit.securis.db.Pack; import net.curisit.securis.db.PackStatus; 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.services.helpers.LicenseHelper; import net.curisit.securis.utils.JsonUtils; import net.curisit.securis.utils.LicUtils; import net.curisit.securis.utils.SignatureHelper; import net.curisit.securis.utils.TokenHelper; /** * ApiResource *

* External API for license operations, intended for third-party clients. * * Endpoints: * - GET /api/ -> Plain-text status with date (health check). * - GET /api/ping -> JSON status (message + date). * - POST /api/request -> Create license from RequestBean (JSON). * - POST /api/request -> Create license from request file (multipart). * - POST /api/renew -> Renew from previous LicenseBean (JSON). * - POST /api/renew -> Renew from previous license file (multipart). * - POST /api/validate -> Server-side validation of a license. * * Security: * - Methods that mutate/inspect licenses require {@link Securable} with role {@link Rol#API_CLIENT}. * - {@link EnsureTransaction} ensures transaction handling at the filter/interceptor layer. * * Errors: * - Business errors are mapped to {@link SeCurisServiceException} with {@link ErrorCodes}. * * @author JRA * Last reviewed by JRA on Oct 5, 2025. */ @Path("/api") public class ApiResource { private static final Logger LOG = LogManager.getLogger(ApiResource.class); @Inject TokenHelper tokenHelper; @Inject private LicenseHelper licenseHelper; @Context EntityManager em; @Inject LicenseGenerator licenseGenerator; /** Fixed username representing API client actor for audit trails. */ public static final String API_CLIENT_USERNAME = "_client"; /** Default constructor (required by JAX-RS). */ public ApiResource() { } // -------------------- Health checks -------------------- /** * index

* Plain text endpoint to verify API is reachable. * * @return 200 OK with simple message */ @GET @Path("/") @Produces({ MediaType.TEXT_PLAIN }) public Response index() { return Response.ok("SeCuris API. Date: " + new Date()).build(); } /** * ping

* JSON endpoint for health checks. * * @return 200 OK with {@link StatusBean} */ @GET @Path("/ping") @Produces({ MediaType.APPLICATION_JSON }) public Response ping() { StatusBean status = new StatusBean(); status.setDate(new Date()); status.setMessage(LicenseManager.PING_MESSAGE); return Response.ok(status).build(); } // -------------------- License creation -------------------- /** * createFromRequest

* Create a new license from JSON request data. * * @param request RequestBean payload * @param nameOrReference Holder name or external reference (header) * @param userEmail Email (header) * @return {@link SignedLicenseBean} JSON */ @POST @Path("/request") @Consumes(MediaType.APPLICATION_JSON) @Securable(roles = Rol.API_CLIENT) @Produces({ MediaType.APPLICATION_JSON }) @EnsureTransaction public Response createFromRequest(RequestBean request, @HeaderParam(LicenseManager.HEADER_LICENSE_NAME_OR_REFERENCE) String nameOrReference, @HeaderParam(LicenseManager.HEADER_LICENSE_EMAIL) String userEmail) throws IOException, SeCurisServiceException, SeCurisException { LOG.info("Request to get license: {}", request); SignedLicenseBean lic = createLicense(request, em, nameOrReference, userEmail); return Response.ok(lic).build(); } /** * createFromRequestFile

* Create a new license from a multipart form (uploaded request fields). * * @param mpfdi multipart input * @param nameOrReference holder name/reference (header) * @param userEmail email (header) * @return {@link SignedLicenseBean} JSON */ @POST @Path("/request") @Consumes(MediaType.MULTIPART_FORM_DATA) @Securable(roles = Rol.API_CLIENT) @Produces({ MediaType.APPLICATION_JSON }) @EnsureTransaction @SuppressWarnings("unchecked") public Response createFromRequestFile(MultipartFormDataInput mpfdi, @HeaderParam(LicenseManager.HEADER_LICENSE_NAME_OR_REFERENCE) String nameOrReference, @HeaderParam(LicenseManager.HEADER_LICENSE_EMAIL) String userEmail) throws IOException, SeCurisServiceException, SeCurisException { RequestBean req = new RequestBean(); req.setAppCode(mpfdi.getFormDataPart("appCode", String.class, null)); req.setActivationCode(mpfdi.getFormDataPart("activationCode", String.class, null)); req.setPackCode(mpfdi.getFormDataPart("packCode", String.class, null)); req.setLicenseTypeCode(mpfdi.getFormDataPart("licenseTypeCode", String.class, null)); req.setCustomerCode(mpfdi.getFormDataPart("customerCode", String.class, null)); req.setArch(mpfdi.getFormDataPart("arch", String.class, null)); req.setCrcLogo(mpfdi.getFormDataPart("crcLogo", String.class, null)); req.setMacAddresses(mpfdi.getFormDataPart("macAddresses", List.class, null)); req.setOsName(mpfdi.getFormDataPart("osName", String.class, null)); return createFromRequest(req, nameOrReference, userEmail); } // -------------------- License renew -------------------- /** * renewFromPreviousLicense

* Renew a license from an existing {@link LicenseBean} JSON payload. * Only Active licenses within one month of expiration are eligible. * * @param previousLic current license bean * @param bsc security context * @return new {@link SignedLicenseBean} */ @POST @Path("/renew") @Consumes(MediaType.APPLICATION_JSON) @Securable(roles = Rol.API_CLIENT) @Produces({ MediaType.APPLICATION_JSON }) @EnsureTransaction public Response renewFromPreviousLicense(LicenseBean previousLic, @Context BasicSecurityContext bsc) throws IOException, SeCurisServiceException, SeCurisException { LOG.info("Renew license: {}", previousLic); if (previousLic.getExpirationDate().after(DateUtils.addMonths(new Date(), 1))) { throw new SeCurisServiceException(ErrorCodes.UNNECESSARY_RENEW, "The license is still valid, not ready for renew"); } License lic = License.findLicenseByCode(previousLic.getLicenseCode(), em); if (lic == null) { throw new SeCurisServiceException(ErrorCodes.LICENSE_DATA_IS_NOT_VALID, "Current license is missing in DB"); } if (lic.getStatus() != LicenseStatus.ACTIVE) { throw new SeCurisServiceException(ErrorCodes.LICENSE_NOT_READY_FOR_RENEW, "Only licenses with status 'Active' can be renew"); } SignedLicenseBean signedLic = renewLicense(previousLic, em); LOG.info("Renewed license code: {}, until: {}", signedLic.getLicenseCode(), signedLic.getExpirationDate()); return Response.ok(signedLic).build(); } /** * renewFromLicenseFile

* Renew a license from multipart (uploaded prior license fields). * * @param mpfdi multipart input * @param bsc security context * @return new {@link SignedLicenseBean} */ @POST @Path("/renew") @Consumes(MediaType.MULTIPART_FORM_DATA) @Securable(roles = Rol.API_CLIENT) @Produces({ MediaType.APPLICATION_JSON }) @EnsureTransaction @SuppressWarnings("unchecked") public Response renewFromLicenseFile(MultipartFormDataInput mpfdi, @Context BasicSecurityContext bsc) throws IOException, SeCurisServiceException, SeCurisException { LicenseBean lic = new LicenseBean(); lic.setAppCode(mpfdi.getFormDataPart("appCode", String.class, null)); lic.setActivationCode(mpfdi.getFormDataPart("activationName", String.class, null)); lic.setAppName(mpfdi.getFormDataPart("appName", String.class, null)); lic.setArch(mpfdi.getFormDataPart("arch", String.class, null)); lic.setCrcLogo(mpfdi.getFormDataPart("crcLogo", String.class, null)); lic.setPackCode(mpfdi.getFormDataPart("packCode", String.class, null)); lic.setLicenseTypeCode(mpfdi.getFormDataPart("licenseCode", String.class, null)); lic.setCustomerCode(mpfdi.getFormDataPart("customerCode", String.class, null)); lic.setMacAddresses(mpfdi.getFormDataPart("macAddresses", List.class, null)); lic.setOsName(mpfdi.getFormDataPart("osName", String.class, null)); lic.setExpirationDate(mpfdi.getFormDataPart("expirationDate", Date.class, null)); LOG.info("Lic expires at: {}", lic.getExpirationDate()); if (lic.getExpirationDate().after(DateUtils.addMonths(new Date(), 1))) { throw new SeCurisServiceException(ErrorCodes.LICENSE_NOT_READY_FOR_RENEW, "The license is still valid, not ready for renew"); } return renewFromPreviousLicense(lic, bsc); } // -------------------- Validation -------------------- /** * validate

* Server-side validation of a license: * - Not expired * - Pack still valid and active * - Signature valid * * @param currentLic license to validate * @param bsc security context * @return same license if valid */ @POST @Path("/validate") @Consumes(MediaType.APPLICATION_JSON) @Securable(roles = Rol.API_CLIENT) @Produces({ MediaType.APPLICATION_JSON }) @EnsureTransaction public Response validate(LicenseBean currentLic, @Context BasicSecurityContext bsc) throws IOException, SeCurisServiceException, SeCurisException { LOG.info("Validate license: {}", currentLic); if (currentLic.getExpirationDate().before(new Date())) { throw new SeCurisServiceException(ErrorCodes.LICENSE_IS_EXPIRED, "The license is expired"); } License existingLic = licenseHelper.getActiveLicenseFromDB(currentLic, em); Pack pack = existingLic.getPack(); if (pack.getEndValidDate().before(new Date())) { throw new SeCurisServiceException(ErrorCodes.LICENSE_PACK_IS_NOT_VALID, "The pack end valid date has been reached"); } if (pack.getStatus() != PackStatus.ACTIVE) { LOG.error("The Pack {} status is not active, is: {}", pack.getCode(), pack.getStatus()); throw new SeCurisServiceException(ErrorCodes.LICENSE_PACK_IS_NOT_VALID, "The pack status is not Active"); } try { SignatureHelper.getInstance().validateSignature(currentLic); } catch (SeCurisException ex) { throw new SeCurisServiceException(ErrorCodes.LICENSE_DATA_IS_NOT_VALID, "The license signature is not valid"); } return Response.ok(currentLic).build(); } // -------------------- Internal helpers -------------------- /** * createLicense

* Creates a new signed license from request data or reuses an existing * pre-active/active one when allowed by business rules. * * @param req request bean * @param em entity manager * @param nameOrReference license holder name/reference (header) * @param email email (header) * @return signed license bean */ private SignedLicenseBean createLicense(RequestBean req, EntityManager em, String nameOrReference, String email) throws SeCurisServiceException { License lic = null; // (1) Activation-code flow if (req.getActivationCode() != null) { lic = License.findLicenseByActivationCode(req.getActivationCode(), em); if (lic == null) { throw new SeCurisServiceException(ErrorCodes.INVALID_DATA, "The given activation code is invalid: " + req.getActivationCode()); } if (lic.getStatus() == LicenseStatus.ACTIVE) { try { RequestBean initialRequest = JsonUtils.json2object(lic.getRequestData(), RequestBean.class); if (!req.match(initialRequest)) { throw new SeCurisServiceException(ErrorCodes.INVALID_DATA, "There is already an active license for given activation code: " + req.getActivationCode()); } else { return JsonUtils.json2object(lic.getLicenseData(), SignedLicenseBean.class); } } catch (SeCurisException e) { LOG.error("Error getting existing license", e); throw new SeCurisServiceException(ErrorCodes.INVALID_FORMAT, "Original request is wrong"); } } else { if (req.getAppCode() != null && !req.getAppCode().equals(lic.getPack().getLicenseType().getApplication().getCode())) { LOG.error("Activation code {} belongs to app: {} but was sent by: {}", req.getActivationCode(), lic.getPack().getLicenseType().getApplication().getCode(), req.getAppCode()); throw new SeCurisServiceException(ErrorCodes.INVALID_DATA, "The given activation code belongs to a different application: " + req.getActivationCode()); } } } else { // (2) Request-data flow (idempotent check) try { lic = License.findValidLicenseByRequestData(JsonUtils.toJSON(req), em); } catch (SeCurisException e1) { throw new SeCurisServiceException(ErrorCodes.INVALID_FORMAT, "Request sent is not valid"); } if (lic != null) { try { if (lic.getStatus() == LicenseStatus.ACTIVE || lic.getStatus() == LicenseStatus.PRE_ACTIVE) { return JsonUtils.json2object(lic.getLicenseData(), SignedLicenseBean.class); } } catch (SeCurisException e) { throw new SeCurisServiceException(ErrorCodes.INVALID_FORMAT, "Error trying to get the license bean from license code: " + lic.getCode()); } } else { lic = new License(); } } // (3) Pack validation & constraints Pack pack; if (lic.getActivationCode() == null) { try { pack = em.createNamedQuery("pack-by-code", Pack.class) .setParameter("code", req.getPackCode()) .getSingleResult(); } catch (NoResultException e) { throw new SeCurisServiceException(ErrorCodes.NOT_FOUND, "No pack found for code: " + req.getPackCode()); } if (pack.getNumAvailables() <= 0) { throw new SeCurisServiceException(ErrorCodes.NO_AVAILABLE_LICENSES, "The current pack has no licenses availables"); } if (lic.getStatus() == LicenseStatus.REQUESTED && !pack.isLicensePreactivation()) { throw new SeCurisServiceException(ErrorCodes.NO_AVAILABLE_LICENSES, "Current pack doesn't allow license preactivation"); } if (!req.getCustomerCode().equals(pack.getOrganization().getCode())) { throw new SeCurisServiceException(ErrorCodes.INVALID_LICENSE_REQUEST_DATA, "Customer code is not valid: " + req.getCustomerCode()); } if (!req.getLicenseTypeCode().equals(pack.getLicenseTypeCode())) { throw new SeCurisServiceException(ErrorCodes.INVALID_LICENSE_REQUEST_DATA, "License type code is not valid: " + req.getLicenseTypeCode()); } } else { pack = lic.getPack(); } if (pack.getStatus() != PackStatus.ACTIVE) { LOG.error("The Pack {} status is not active, is: {}", pack.getCode(), pack.getStatus()); throw new SeCurisServiceException(ErrorCodes.LICENSE_DATA_IS_NOT_VALID, "The pack status is not Active"); } // (4) License generation SignedLicenseBean signedLicense; try { String licCode = (lic.getCode() == null) ? LicUtils.getLicenseCode(pack.getCode(), licenseHelper.getNextCodeSuffix(pack.getId(), em)) : lic.getCode(); Date expirationDate = licenseHelper.getExpirationDateFromPack(pack, lic.getActivationCode() == null); LicenseBean lb = licenseGenerator.generateLicense( req, licenseHelper.extractPackMetadata(pack.getMetadata()), expirationDate, licCode, pack.getAppName() ); signedLicense = new SignedLicenseBean(lb); } catch (SeCurisException e) { throw new SeCurisServiceException(ErrorCodes.INVALID_LICENSE_REQUEST_DATA, "Error generating license: " + e.toString()); } // (5) Persist/merge license + history try { lic.setRequestData(JsonUtils.toJSON(req)); if (BlockedRequest.isRequestBlocked(lic.getRequestData(), em)) { throw new SeCurisServiceException(ErrorCodes.BLOCKED_REQUEST_DATA, "Given request data is blocked and cannot be activated"); } lic.setLicenseData(JsonUtils.toJSON(signedLicense)); } catch (SeCurisException e) { LOG.error("Error generating license JSON", e); throw new SeCurisServiceException(ErrorCodes.INVALID_FORMAT, "Error generating license JSON"); } lic.setModificationTimestamp(new Date()); lic.setExpirationDate(signedLicense.getExpirationDate()); User user = em.find(User.class, API_CLIENT_USERNAME); if (lic.getStatus() != LicenseStatus.REQUESTED) { lic.setPack(pack); lic.setCreatedBy(user); lic.setCreationTimestamp(new Date()); if (lic.getActivationCode() != null) { lic.setStatus(LicenseStatus.ACTIVE); } else { lic.setStatus(pack.isLicensePreactivation() ? LicenseStatus.PRE_ACTIVE : LicenseStatus.REQUESTED); } lic.setCode(signedLicense.getLicenseCode()); lic.setCodeSuffix(LicUtils.getLicenseCodeSuffix(signedLicense.getLicenseCode())); if (lic.getEmail() == null || "".equals(lic.getEmail())) { lic.setEmail(email); } if (lic.getFullName() == null || "".equals(lic.getFullName())) { lic.setFullName(nameOrReference); } if (lic.getId() != null) { em.merge(lic); } else { em.persist(lic); } em.persist(licenseHelper.createLicenseHistoryAction(lic, user, LicenseHistory.Actions.CREATE)); if (lic.getActivationCode() != null) { em.persist(licenseHelper.createLicenseHistoryAction(lic, user, LicenseHistory.Actions.ACTIVATE, "Activated by code on creation")); } else { if (pack.isLicensePreactivation()) { em.persist(licenseHelper.createLicenseHistoryAction(lic, user, LicenseHistory.Actions.PRE_ACTIVATE, "Pre-activated on creation")); } else { LOG.warn("License ({}) created, but the pack doesn't allow preactivation", lic.getCode()); throw new SeCurisServiceException(ErrorCodes.NO_AVAILABLE_LICENSES, "Current pack doesn't allow license preactivation"); } } } else { lic.setStatus(LicenseStatus.PRE_ACTIVE); em.merge(lic); em.persist(licenseHelper.createLicenseHistoryAction(lic, user, LicenseHistory.Actions.PRE_ACTIVATE, "Pre-activated after request")); } return signedLicense; } /** * renewLicense

* Internal renew logic used by JSON and multipart variants. * * @param previousLicenseBean previous license data * @param em entity manager * @return new signed license bean */ private SignedLicenseBean renewLicense(LicenseBean previousLicenseBean, EntityManager em) throws SeCurisServiceException { License lic = License.findLicenseByCode(previousLicenseBean.getLicenseCode(), em); if (lic.getStatus() != LicenseStatus.ACTIVE && lic.getStatus() != LicenseStatus.PRE_ACTIVE) { throw new SeCurisServiceException(ErrorCodes.INVALID_DATA, "The current license has been cancelled"); } Pack pack = lic.getPack(); SignedLicenseBean signedLicense; try { String licCode = lic.getCode(); Date expirationDate = licenseHelper.getExpirationDateFromPack(pack, false); LicenseBean lb = licenseGenerator.generateLicense( previousLicenseBean, licenseHelper.extractPackMetadata(pack.getMetadata()), expirationDate, licCode, pack.getAppName() ); signedLicense = new SignedLicenseBean(lb); } catch (SeCurisException e) { throw new SeCurisServiceException(ErrorCodes.INVALID_LICENSE_REQUEST_DATA, "Error generating license: " + e.toString()); } try { lic.setRequestData(JsonUtils.toJSON(signedLicense, RequestBean.class)); if (BlockedRequest.isRequestBlocked(lic.getRequestData(), em)) { throw new SeCurisServiceException(ErrorCodes.BLOCKED_REQUEST_DATA, "Given request data is blocked and cannot be activated"); } lic.setLicenseData(JsonUtils.toJSON(signedLicense)); } catch (SeCurisException e) { LOG.error("Error generating license JSON", e); throw new SeCurisServiceException(ErrorCodes.INVALID_FORMAT, "Error generating license JSON"); } lic.setModificationTimestamp(new Date()); lic.setExpirationDate(signedLicense.getExpirationDate()); User user = em.find(User.class, API_CLIENT_USERNAME); lic.setStatus(LicenseStatus.ACTIVE); em.merge(lic); em.persist(licenseHelper.createLicenseHistoryAction(lic, user, LicenseHistory.Actions.RENEW)); return signedLicense; } }