| .. | .. |
|---|
| 1 | +/* |
|---|
| 2 | + * Copyright @ 2013 CurisTEC, S.A.S. All Rights Reserved. |
|---|
| 3 | + */ |
|---|
| 1 | 4 | package net.curisit.securis.utils; |
|---|
| 2 | 5 | |
|---|
| 3 | 6 | import java.io.IOException; |
|---|
| .. | .. |
|---|
| 20 | 23 | import java.util.Base64; |
|---|
| 21 | 24 | import java.nio.charset.StandardCharsets; |
|---|
| 22 | 25 | |
|---|
| 26 | +/** |
|---|
| 27 | + * TokenHelper |
|---|
| 28 | + * <p> |
|---|
| 29 | + * Utility component to generate and validate short-lived authentication tokens |
|---|
| 30 | + * for SeCuris services. Tokens are: |
|---|
| 31 | + * </p> |
|---|
| 32 | + * <ul> |
|---|
| 33 | + * <li>Base64-encoded UTF-8 strings.</li> |
|---|
| 34 | + * <li>Composed as: {@code <secret> <username> <iso8601-timestamp>} (space-separated).</li> |
|---|
| 35 | + * <li>Where {@code secret} is a deterministic SHA-256 HMAC-like hash built from a static seed, |
|---|
| 36 | + * the username, and the issuance timestamp.</li> |
|---|
| 37 | + * </ul> |
|---|
| 38 | + * |
|---|
| 39 | + * <p><b>Lifecycle & scope:</b> {@code @ApplicationScoped}. Stateless and thread-safe.</p> |
|---|
| 40 | + * |
|---|
| 41 | + * <p><b>Security notes:</b> |
|---|
| 42 | + * The {@code seed} acts like a shared secret. Keep it private and rotate if compromised. |
|---|
| 43 | + * Tokens expire after {@link #VALID_TOKEN_PERIOD} hours except a special client token |
|---|
| 44 | + * defined by {@link ApiResource#API_CLIENT_USERNAME} issued at epoch-1 (see {@link #isTokenValid(String)}). |
|---|
| 45 | + * </p> |
|---|
| 46 | + * |
|---|
| 47 | + * <p><b>Format details:</b> |
|---|
| 48 | + * <pre> |
|---|
| 49 | + * token = Base64( secret + " " + user + " " + Utils.toIsoFormat(date) ) |
|---|
| 50 | + * secret = hex(SHA-256(seed || user || isoDate)) |
|---|
| 51 | + * </pre> |
|---|
| 52 | + * </p> |
|---|
| 53 | + * |
|---|
| 54 | + * @author JRA |
|---|
| 55 | + * Last reviewed by JRA on Oct 6, 2025. |
|---|
| 56 | + */ |
|---|
| 23 | 57 | @ApplicationScoped |
|---|
| 24 | 58 | public class TokenHelper { |
|---|
| 25 | 59 | |
|---|
| 26 | 60 | private static final Logger LOG = LogManager.getLogger(TokenHelper.class); |
|---|
| 27 | 61 | |
|---|
| 28 | 62 | /** |
|---|
| 29 | | - * Period before token expires, set in hours. |
|---|
| 63 | + * Validity window for standard tokens, in hours. |
|---|
| 64 | + * <p> |
|---|
| 65 | + * Any token with a creation date older than this window will be rejected |
|---|
| 66 | + * (unless it matches the special API client rule documented in |
|---|
| 67 | + * {@link #isTokenValid(String)}). |
|---|
| 68 | + * </p> |
|---|
| 30 | 69 | */ |
|---|
| 31 | 70 | private static int VALID_TOKEN_PERIOD = 24; |
|---|
| 71 | + |
|---|
| 72 | + /** Standard HTTP header used by SeCuris clients to carry the token. */ |
|---|
| 32 | 73 | public static final String TOKEN_HEADER_PÀRAM = "X-SECURIS-TOKEN"; |
|---|
| 33 | 74 | |
|---|
| 75 | + /** |
|---|
| 76 | + * TokenHelper<p> |
|---|
| 77 | + * CDI no-arg constructor. |
|---|
| 78 | + * <p> |
|---|
| 79 | + * Kept for dependency injection. No initialization logic is required. |
|---|
| 80 | + * </p> |
|---|
| 81 | + */ |
|---|
| 34 | 82 | @Inject |
|---|
| 35 | 83 | public TokenHelper() { |
|---|
| 36 | 84 | } |
|---|
| 37 | 85 | |
|---|
| 86 | + /** |
|---|
| 87 | + * Static secret seed used to derive the token {@code secret} portion. |
|---|
| 88 | + * <p> |
|---|
| 89 | + * Treat this as confidential. Changing it invalidates all outstanding tokens. |
|---|
| 90 | + * </p> |
|---|
| 91 | + */ |
|---|
| 38 | 92 | private static byte[] seed = "S3Cur15S33dForT0k3nG3n3r@tion".getBytes(); |
|---|
| 39 | 93 | |
|---|
| 94 | + // --------------------------------------------------------------------- |
|---|
| 95 | + // Token generation |
|---|
| 96 | + // --------------------------------------------------------------------- |
|---|
| 97 | + |
|---|
| 40 | 98 | /** |
|---|
| 41 | | - * Generate a token encoded in Base64 for user passed as parameter and |
|---|
| 42 | | - * taking the current moment as token timestamp |
|---|
| 43 | | - * |
|---|
| 44 | | - * @param user |
|---|
| 45 | | - * @return |
|---|
| 99 | + * generateToken |
|---|
| 100 | + * <p> |
|---|
| 101 | + * Convenience overload that generates a token for {@code user} using the current |
|---|
| 102 | + * system time as the issuance timestamp. |
|---|
| 103 | + * </p> |
|---|
| 104 | + * |
|---|
| 105 | + * @param user Username to embed in the token (must be non-null/non-empty). |
|---|
| 106 | + * @return Base64-encoded token string, or {@code null} if a cryptographic error occurs. |
|---|
| 46 | 107 | */ |
|---|
| 47 | 108 | public String generateToken(String user) { |
|---|
| 48 | | - |
|---|
| 49 | 109 | return generateToken(user, new Date()); |
|---|
| 50 | 110 | } |
|---|
| 51 | 111 | |
|---|
| 52 | | - ; |
|---|
| 53 | | - |
|---|
| 112 | + /** |
|---|
| 113 | + * generateToken |
|---|
| 114 | + * <p> |
|---|
| 115 | + * Builds a token for a given user and issuance date. The token body is: |
|---|
| 116 | + * </p> |
|---|
| 117 | + * <pre> |
|---|
| 118 | + * secret + " " + user + " " + Utils.toIsoFormat(date) |
|---|
| 119 | + * </pre> |
|---|
| 120 | + * <p> |
|---|
| 121 | + * and then Base64-encoded in UTF-8. |
|---|
| 122 | + * </p> |
|---|
| 123 | + * |
|---|
| 124 | + * @param user Username to embed. |
|---|
| 125 | + * @param date Issuance date to include in the token (affects expiry and secret derivation). |
|---|
| 126 | + * @return Base64 token, or {@code null} upon failure. |
|---|
| 127 | + */ |
|---|
| 54 | 128 | public String generateToken(String user, Date date) { |
|---|
| 55 | 129 | try { |
|---|
| 56 | 130 | String secret = generateSecret(user, date); |
|---|
| .. | .. |
|---|
| 61 | 135 | sb.append(' '); |
|---|
| 62 | 136 | sb.append(Utils.toIsoFormat(date)); |
|---|
| 63 | 137 | |
|---|
| 64 | | - // Codificación estándar con UTF-8 |
|---|
| 138 | + // Standard UTF-8 encoding before Base64 |
|---|
| 65 | 139 | return Base64.getEncoder().encodeToString(sb.toString().getBytes(StandardCharsets.UTF_8)); |
|---|
| 66 | 140 | |
|---|
| 67 | 141 | } catch (NoSuchAlgorithmException e) { |
|---|
| .. | .. |
|---|
| 72 | 146 | return null; |
|---|
| 73 | 147 | } |
|---|
| 74 | 148 | |
|---|
| 149 | + /** |
|---|
| 150 | + * generateSecret |
|---|
| 151 | + * <p> |
|---|
| 152 | + * Derives the deterministic secret (a 64-hex-character SHA-256 digest) used to |
|---|
| 153 | + * authenticate a token. Inputs are concatenated in the following order: |
|---|
| 154 | + * </p> |
|---|
| 155 | + * <ol> |
|---|
| 156 | + * <li>{@link #seed}</li> |
|---|
| 157 | + * <li>{@code user} (UTF-8 bytes)</li> |
|---|
| 158 | + * <li>{@code Utils.toIsoFormat(date)}</li> |
|---|
| 159 | + * </ol> |
|---|
| 160 | + * |
|---|
| 161 | + * @param user Username to mix in the digest. |
|---|
| 162 | + * @param date Token issuance date to mix in the digest. |
|---|
| 163 | + * @return 64-char hex string. |
|---|
| 164 | + * @throws UnsupportedEncodingException If UTF-8 is unavailable (unexpected). |
|---|
| 165 | + * @throws NoSuchAlgorithmException If SHA-256 is unavailable (unexpected). |
|---|
| 166 | + */ |
|---|
| 75 | 167 | private String generateSecret(String user, Date date) throws UnsupportedEncodingException, NoSuchAlgorithmException { |
|---|
| 76 | 168 | MessageDigest mDigest = MessageDigest.getInstance("SHA-256"); |
|---|
| 77 | 169 | mDigest.update(seed, 0, seed.length); |
|---|
| 170 | + |
|---|
| 78 | 171 | byte[] userbytes = user.getBytes("utf-8"); |
|---|
| 79 | 172 | mDigest.update(userbytes, 0, userbytes.length); |
|---|
| 173 | + |
|---|
| 80 | 174 | byte[] isodate = Utils.toIsoFormat(date).getBytes(); |
|---|
| 81 | 175 | mDigest.update(isodate, 0, isodate.length); |
|---|
| 176 | + |
|---|
| 82 | 177 | BigInteger i = new BigInteger(1, mDigest.digest()); |
|---|
| 83 | 178 | String secret = String.format("%1$064x", i); |
|---|
| 84 | 179 | return secret; |
|---|
| 85 | 180 | } |
|---|
| 86 | 181 | |
|---|
| 182 | + // --------------------------------------------------------------------- |
|---|
| 183 | + // Token validation & parsing |
|---|
| 184 | + // --------------------------------------------------------------------- |
|---|
| 185 | + |
|---|
| 87 | 186 | /** |
|---|
| 88 | | - * Check if passed token is still valid, It use to check if token is expired |
|---|
| 89 | | - * the attribute VALID_TOKEN_PERIOD (in hours) |
|---|
| 90 | | - * |
|---|
| 91 | | - * @param token |
|---|
| 92 | | - * @return |
|---|
| 187 | + * isTokenValid |
|---|
| 188 | + * <p> |
|---|
| 189 | + * Validates the structure, signature and expiry of the given token. |
|---|
| 190 | + * Steps performed: |
|---|
| 191 | + * </p> |
|---|
| 192 | + * <ol> |
|---|
| 193 | + * <li>Base64-decode the token into {@code "secret user isoDate"}.</li> |
|---|
| 194 | + * <li>Parse {@code user} and {@code isoDate}; recompute the expected secret via |
|---|
| 195 | + * {@link #generateSecret(String, Date)} and compare with the provided one.</li> |
|---|
| 196 | + * <li>Check expiry: if the token's timestamp is older than |
|---|
| 197 | + * {@link #VALID_TOKEN_PERIOD} hours, it's invalid.</li> |
|---|
| 198 | + * <li>Special-case: if {@code user} equals {@link ApiResource#API_CLIENT_USERNAME} |
|---|
| 199 | + * and the date returns a non-positive epoch time (e.g., created with {@code new Date(-1)}), |
|---|
| 200 | + * the expiry check is skipped (client integration token).</li> |
|---|
| 201 | + * </ol> |
|---|
| 202 | + * |
|---|
| 203 | + * @param token Base64 token string. |
|---|
| 204 | + * @return {@code true} if valid and not expired; {@code false} otherwise. |
|---|
| 93 | 205 | */ |
|---|
| 94 | 206 | public boolean isTokenValid(String token) { |
|---|
| 95 | 207 | try { |
|---|
| 96 | | - String tokenDecoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); |
|---|
| 208 | + String tokenDecoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); |
|---|
| 97 | 209 | String[] parts = StringUtils.split(tokenDecoded, ' '); |
|---|
| 98 | 210 | if (parts == null || parts.length < 3) { |
|---|
| 99 | 211 | return false; |
|---|
| 100 | 212 | } |
|---|
| 213 | + |
|---|
| 101 | 214 | String secret = parts[0]; |
|---|
| 102 | 215 | String user = parts[1]; |
|---|
| 103 | 216 | Date date = Utils.toDateFromIso(parts[2]); |
|---|
| 217 | + |
|---|
| 218 | + // Expiry check (unless special client token rule applies) |
|---|
| 104 | 219 | if (date.getTime() > 0 || !user.equals(ApiResource.API_CLIENT_USERNAME)) { |
|---|
| 105 | 220 | if (new Date().after(new Date(date.getTime() + VALID_TOKEN_PERIOD * 60 * 60 * 1000))) { |
|---|
| 106 | 221 | return false; |
|---|
| 107 | 222 | } |
|---|
| 108 | | - } // else: It's a securis-client API call |
|---|
| 223 | + } |
|---|
| 224 | + |
|---|
| 225 | + // Signature check |
|---|
| 109 | 226 | String newSecret = generateSecret(user, date); |
|---|
| 110 | 227 | return newSecret.equals(secret); |
|---|
| 228 | + |
|---|
| 111 | 229 | } catch (IOException e) { |
|---|
| 112 | 230 | LOG.error("Error decoding Base64 token", e); |
|---|
| 113 | 231 | } catch (NoSuchAlgorithmException e) { |
|---|
| .. | .. |
|---|
| 116 | 234 | return false; |
|---|
| 117 | 235 | } |
|---|
| 118 | 236 | |
|---|
| 237 | + /** |
|---|
| 238 | + * extractUserFromToken |
|---|
| 239 | + * <p> |
|---|
| 240 | + * Extracts the username portion from a validly structured token. |
|---|
| 241 | + * </p> |
|---|
| 242 | + * |
|---|
| 243 | + * @param token Base64 token string (may be {@code null}). |
|---|
| 244 | + * @return Username if the token has at least three space-separated fields after decoding; |
|---|
| 245 | + * {@code null} on error or malformed input. |
|---|
| 246 | + */ |
|---|
| 119 | 247 | public String extractUserFromToken(String token) { |
|---|
| 120 | 248 | try { |
|---|
| 121 | 249 | if (token == null) { |
|---|
| .. | .. |
|---|
| 134 | 262 | return null; |
|---|
| 135 | 263 | } |
|---|
| 136 | 264 | |
|---|
| 265 | + /** |
|---|
| 266 | + * extractDateCreationFromToken |
|---|
| 267 | + * <p> |
|---|
| 268 | + * Parses and returns the issuance {@link Date} embedded in the token, without |
|---|
| 269 | + * performing validation or expiry checks. |
|---|
| 270 | + * </p> |
|---|
| 271 | + * |
|---|
| 272 | + * @param token Base64 token string. |
|---|
| 273 | + * @return Issuance {@link Date}, or {@code null} if the token is malformed or cannot be decoded. |
|---|
| 274 | + */ |
|---|
| 137 | 275 | public Date extractDateCreationFromToken(String token) { |
|---|
| 138 | 276 | try { |
|---|
| 139 | 277 | String tokenDecoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); |
|---|
| .. | .. |
|---|
| 149 | 287 | return null; |
|---|
| 150 | 288 | } |
|---|
| 151 | 289 | |
|---|
| 290 | + // --------------------------------------------------------------------- |
|---|
| 291 | + // Demo / manual test |
|---|
| 292 | + // --------------------------------------------------------------------- |
|---|
| 293 | + |
|---|
| 294 | + /** |
|---|
| 295 | + * main |
|---|
| 296 | + * <p> |
|---|
| 297 | + * Simple manual test demonstrating generation and validation of a special |
|---|
| 298 | + * "_client" token that bypasses expiry via a negative epoch date. |
|---|
| 299 | + * </p> |
|---|
| 300 | + * |
|---|
| 301 | + * @param args CLI args (unused). |
|---|
| 302 | + * @throws IOException If something goes wrong while encoding/decoding Base64 (unlikely). |
|---|
| 303 | + */ |
|---|
| 152 | 304 | public static void main(String[] args) throws IOException { |
|---|
| 153 | 305 | // client token: |
|---|
| 154 | 306 | // OTk3ODRiMzY5NzQ5MWI5NmYyZGQyODRiYjY2ZTU2YzdmMTZjYzM3YTY3N2ExM2M3ODI2MjU5ZTMzOTIyYjUzNSBfY2xpZW50IDE5NzAtMDEtMDFUMDA6NTk6NTkuOTk5KzAxMDA= |
|---|
| .. | .. |
|---|
| 160 | 312 | System.out.println("is valid client token: " + new TokenHelper().isTokenValid(t)); |
|---|
| 161 | 313 | } |
|---|
| 162 | 314 | } |
|---|
| 315 | + |
|---|