Joaquín Reñé
2025-10-07 146a0fb8b0e90f9196e569152f649baf60d6cc8f
securis/src/main/java/net/curisit/securis/utils/TokenHelper.java
....@@ -1,3 +1,6 @@
1
+/*
2
+ * Copyright @ 2013 CurisTEC, S.A.S. All Rights Reserved.
3
+ */
14 package net.curisit.securis.utils;
25
36 import java.io.IOException;
....@@ -20,37 +23,108 @@
2023 import java.util.Base64;
2124 import java.nio.charset.StandardCharsets;
2225
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
+ */
2357 @ApplicationScoped
2458 public class TokenHelper {
2559
2660 private static final Logger LOG = LogManager.getLogger(TokenHelper.class);
2761
2862 /**
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>
3069 */
3170 private static int VALID_TOKEN_PERIOD = 24;
71
+
72
+ /** Standard HTTP header used by SeCuris clients to carry the token. */
3273 public static final String TOKEN_HEADER_PÀRAM = "X-SECURIS-TOKEN";
3374
75
+ /**
76
+ * TokenHelper<p>
77
+ * CDI no-arg constructor.
78
+ * <p>
79
+ * Kept for dependency injection. No initialization logic is required.
80
+ * </p>
81
+ */
3482 @Inject
3583 public TokenHelper() {
3684 }
3785
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
+ */
3892 private static byte[] seed = "S3Cur15S33dForT0k3nG3n3r@tion".getBytes();
3993
94
+ // ---------------------------------------------------------------------
95
+ // Token generation
96
+ // ---------------------------------------------------------------------
97
+
4098 /**
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.
46107 */
47108 public String generateToken(String user) {
48
-
49109 return generateToken(user, new Date());
50110 }
51111
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
+ */
54128 public String generateToken(String user, Date date) {
55129 try {
56130 String secret = generateSecret(user, date);
....@@ -61,7 +135,7 @@
61135 sb.append(' ');
62136 sb.append(Utils.toIsoFormat(date));
63137
64
- // Codificación estándar con UTF-8
138
+ // Standard UTF-8 encoding before Base64
65139 return Base64.getEncoder().encodeToString(sb.toString().getBytes(StandardCharsets.UTF_8));
66140
67141 } catch (NoSuchAlgorithmException e) {
....@@ -72,42 +146,86 @@
72146 return null;
73147 }
74148
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
+ */
75167 private String generateSecret(String user, Date date) throws UnsupportedEncodingException, NoSuchAlgorithmException {
76168 MessageDigest mDigest = MessageDigest.getInstance("SHA-256");
77169 mDigest.update(seed, 0, seed.length);
170
+
78171 byte[] userbytes = user.getBytes("utf-8");
79172 mDigest.update(userbytes, 0, userbytes.length);
173
+
80174 byte[] isodate = Utils.toIsoFormat(date).getBytes();
81175 mDigest.update(isodate, 0, isodate.length);
176
+
82177 BigInteger i = new BigInteger(1, mDigest.digest());
83178 String secret = String.format("%1$064x", i);
84179 return secret;
85180 }
86181
182
+ // ---------------------------------------------------------------------
183
+ // Token validation & parsing
184
+ // ---------------------------------------------------------------------
185
+
87186 /**
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.
93205 */
94206 public boolean isTokenValid(String token) {
95207 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);
97209 String[] parts = StringUtils.split(tokenDecoded, ' ');
98210 if (parts == null || parts.length < 3) {
99211 return false;
100212 }
213
+
101214 String secret = parts[0];
102215 String user = parts[1];
103216 Date date = Utils.toDateFromIso(parts[2]);
217
+
218
+ // Expiry check (unless special client token rule applies)
104219 if (date.getTime() > 0 || !user.equals(ApiResource.API_CLIENT_USERNAME)) {
105220 if (new Date().after(new Date(date.getTime() + VALID_TOKEN_PERIOD * 60 * 60 * 1000))) {
106221 return false;
107222 }
108
- } // else: It's a securis-client API call
223
+ }
224
+
225
+ // Signature check
109226 String newSecret = generateSecret(user, date);
110227 return newSecret.equals(secret);
228
+
111229 } catch (IOException e) {
112230 LOG.error("Error decoding Base64 token", e);
113231 } catch (NoSuchAlgorithmException e) {
....@@ -116,6 +234,16 @@
116234 return false;
117235 }
118236
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
+ */
119247 public String extractUserFromToken(String token) {
120248 try {
121249 if (token == null) {
....@@ -134,6 +262,16 @@
134262 return null;
135263 }
136264
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
+ */
137275 public Date extractDateCreationFromToken(String token) {
138276 try {
139277 String tokenDecoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
....@@ -149,6 +287,20 @@
149287 return null;
150288 }
151289
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
+ */
152304 public static void main(String[] args) throws IOException {
153305 // client token:
154306 // OTk3ODRiMzY5NzQ5MWI5NmYyZGQyODRiYjY2ZTU2YzdmMTZjYzM3YTY3N2ExM2M3ODI2MjU5ZTMzOTIyYjUzNSBfY2xpZW50IDE5NzAtMDEtMDFUMDA6NTk6NTkuOTk5KzAxMDA=
....@@ -160,3 +312,4 @@
160312 System.out.println("is valid client token: " + new TokenHelper().isTokenValid(t));
161313 }
162314 }
315
+