/* * Copyright @ 2013 CurisTEC, S.A.S. All Rights Reserved. */ package net.curisit.securis.utils; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Date; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import net.curisit.integrity.commons.Utils; import net.curisit.securis.services.ApiResource; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.Base64; import java.nio.charset.StandardCharsets; /** * TokenHelper *

* Utility component to generate and validate short-lived authentication tokens * for SeCuris services. Tokens are: *

* * *

Lifecycle & scope: {@code @ApplicationScoped}. Stateless and thread-safe.

* *

Security notes: * The {@code seed} acts like a shared secret. Keep it private and rotate if compromised. * Tokens expire after {@link #VALID_TOKEN_PERIOD} hours except a special client token * defined by {@link ApiResource#API_CLIENT_USERNAME} issued at epoch-1 (see {@link #isTokenValid(String)}). *

* *

Format details: *

 *   token = Base64( secret + " " + user + " " + Utils.toIsoFormat(date) )
 *   secret = hex(SHA-256(seed || user || isoDate))
 *   
*

* * @author JRA * Last reviewed by JRA on Oct 6, 2025. */ @ApplicationScoped public class TokenHelper { private static final Logger LOG = LogManager.getLogger(TokenHelper.class); /** * Validity window for standard tokens, in hours. *

* Any token with a creation date older than this window will be rejected * (unless it matches the special API client rule documented in * {@link #isTokenValid(String)}). *

*/ private static int VALID_TOKEN_PERIOD = 24; /** Standard HTTP header used by SeCuris clients to carry the token. */ public static final String TOKEN_HEADER_PĂ€RAM = "X-SECURIS-TOKEN"; /** * TokenHelper

* CDI no-arg constructor. *

* Kept for dependency injection. No initialization logic is required. *

*/ @Inject public TokenHelper() { } /** * Static secret seed used to derive the token {@code secret} portion. *

* Treat this as confidential. Changing it invalidates all outstanding tokens. *

*/ private static byte[] seed = "S3Cur15S33dForT0k3nG3n3r@tion".getBytes(); // --------------------------------------------------------------------- // Token generation // --------------------------------------------------------------------- /** * generateToken *

* Convenience overload that generates a token for {@code user} using the current * system time as the issuance timestamp. *

* * @param user Username to embed in the token (must be non-null/non-empty). * @return Base64-encoded token string, or {@code null} if a cryptographic error occurs. */ public String generateToken(String user) { return generateToken(user, new Date()); } /** * generateToken *

* Builds a token for a given user and issuance date. The token body is: *

*
     * secret + " " + user + " " + Utils.toIsoFormat(date)
     * 
*

* and then Base64-encoded in UTF-8. *

* * @param user Username to embed. * @param date Issuance date to include in the token (affects expiry and secret derivation). * @return Base64 token, or {@code null} upon failure. */ public String generateToken(String user, Date date) { try { String secret = generateSecret(user, date); StringBuilder sb = new StringBuilder(); sb.append(secret); sb.append(' '); sb.append(user); sb.append(' '); sb.append(Utils.toIsoFormat(date)); // Standard UTF-8 encoding before Base64 return Base64.getEncoder().encodeToString(sb.toString().getBytes(StandardCharsets.UTF_8)); } catch (NoSuchAlgorithmException e) { LOG.error("Error generating SHA-256 hash", e); } catch (UnsupportedEncodingException e) { LOG.error("Error generating SHA-256 hash", e); } return null; } /** * generateSecret *

* Derives the deterministic secret (a 64-hex-character SHA-256 digest) used to * authenticate a token. Inputs are concatenated in the following order: *

*
    *
  1. {@link #seed}
  2. *
  3. {@code user} (UTF-8 bytes)
  4. *
  5. {@code Utils.toIsoFormat(date)}
  6. *
* * @param user Username to mix in the digest. * @param date Token issuance date to mix in the digest. * @return 64-char hex string. * @throws UnsupportedEncodingException If UTF-8 is unavailable (unexpected). * @throws NoSuchAlgorithmException If SHA-256 is unavailable (unexpected). */ private String generateSecret(String user, Date date) throws UnsupportedEncodingException, NoSuchAlgorithmException { MessageDigest mDigest = MessageDigest.getInstance("SHA-256"); mDigest.update(seed, 0, seed.length); byte[] userbytes = user.getBytes("utf-8"); mDigest.update(userbytes, 0, userbytes.length); byte[] isodate = Utils.toIsoFormat(date).getBytes(); mDigest.update(isodate, 0, isodate.length); BigInteger i = new BigInteger(1, mDigest.digest()); String secret = String.format("%1$064x", i); return secret; } // --------------------------------------------------------------------- // Token validation & parsing // --------------------------------------------------------------------- /** * isTokenValid *

* Validates the structure, signature and expiry of the given token. * Steps performed: *

*
    *
  1. Base64-decode the token into {@code "secret user isoDate"}.
  2. *
  3. Parse {@code user} and {@code isoDate}; recompute the expected secret via * {@link #generateSecret(String, Date)} and compare with the provided one.
  4. *
  5. Check expiry: if the token's timestamp is older than * {@link #VALID_TOKEN_PERIOD} hours, it's invalid.
  6. *
  7. Special-case: if {@code user} equals {@link ApiResource#API_CLIENT_USERNAME} * and the date returns a non-positive epoch time (e.g., created with {@code new Date(-1)}), * the expiry check is skipped (client integration token).
  8. *
* * @param token Base64 token string. * @return {@code true} if valid and not expired; {@code false} otherwise. */ public boolean isTokenValid(String token) { try { String tokenDecoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); String[] parts = StringUtils.split(tokenDecoded, ' '); if (parts == null || parts.length < 3) { return false; } String secret = parts[0]; String user = parts[1]; Date date = Utils.toDateFromIso(parts[2]); // Expiry check (unless special client token rule applies) if (date.getTime() > 0 || !user.equals(ApiResource.API_CLIENT_USERNAME)) { if (new Date().after(new Date(date.getTime() + VALID_TOKEN_PERIOD * 60 * 60 * 1000))) { return false; } } // Signature check String newSecret = generateSecret(user, date); return newSecret.equals(secret); } catch (IOException e) { LOG.error("Error decoding Base64 token", e); } catch (NoSuchAlgorithmException e) { LOG.error("Error generation secret to compare with", e); } return false; } /** * extractUserFromToken *

* Extracts the username portion from a validly structured token. *

* * @param token Base64 token string (may be {@code null}). * @return Username if the token has at least three space-separated fields after decoding; * {@code null} on error or malformed input. */ public String extractUserFromToken(String token) { try { if (token == null) { return null; } String tokenDecoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); String[] parts = StringUtils.split(tokenDecoded, ' '); if (parts == null || parts.length < 3) { return null; } String user = parts[1]; return user; } catch (Exception e) { LOG.error("Error decoding Base64 token", e); } return null; } /** * extractDateCreationFromToken *

* Parses and returns the issuance {@link Date} embedded in the token, without * performing validation or expiry checks. *

* * @param token Base64 token string. * @return Issuance {@link Date}, or {@code null} if the token is malformed or cannot be decoded. */ public Date extractDateCreationFromToken(String token) { try { String tokenDecoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); String[] parts = StringUtils.split(tokenDecoded, ' '); if (parts == null || parts.length < 3) { return null; } Date date = Utils.toDateFromIso(parts[2]); return date; } catch (Exception e) { LOG.error("Error decoding Base64 token", e); } return null; } // --------------------------------------------------------------------- // Demo / manual test // --------------------------------------------------------------------- /** * main *

* Simple manual test demonstrating generation and validation of a special * "_client" token that bypasses expiry via a negative epoch date. *

* * @param args CLI args (unused). * @throws IOException If something goes wrong while encoding/decoding Base64 (unlikely). */ public static void main(String[] args) throws IOException { // client token: // OTk3ODRiMzY5NzQ5MWI5NmYyZGQyODRiYjY2ZTU2YzdmMTZjYzM3YTY3N2ExM2M3ODI2MjU5ZTMzOTIyYjUzNSBfY2xpZW50IDE5NzAtMDEtMDFUMDA6NTk6NTkuOTk5KzAxMDA= // OTk3ODRiMzY5NzQ5MWI5NmYyZGQyODRiYjY2ZTU2YzdmMTZjYzM3YTY3N2ExM2M3ODI2MjU5ZTMzOTIyYjUzNSBfY2xpZW50IDE5NzAtMDEtMDFUMDA6NTk6NTkuOTk5KzAxMDA= String t = new TokenHelper().generateToken("_client", new Date(-1)); System.out.println("client token: " + t); System.out.println("client token: " + new String(Base64.getDecoder().decode(t), StandardCharsets.UTF_8)); System.out.println("is valid client token: " + new TokenHelper().isTokenValid(t)); } }