-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathJwtService.java
More file actions
362 lines (328 loc) · 13.3 KB
/
JwtService.java
File metadata and controls
362 lines (328 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
package contactapp.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.DecodingException;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
/**
* Service for JWT token generation and validation.
* Uses HMAC-SHA256 for signing tokens.
*
* <p>Configuration properties:
* <ul>
* <li>jwt.secret - Base64-encoded secret key (min 256 bits). <b>Required</b> - application
* will fail to start if not configured. Raw strings are still accepted for backward
* compatibility but strongly discouraged.</li>
* <li>jwt.expiration - Token expiration time in milliseconds (default: 30m)</li>
* </ul>
*
* <p><b>Security Note:</b> The jwt.secret must be configured via environment variable or
* secrets manager. Never commit secrets to source control.
*/
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration:1800000}")
private long jwtExpiration;
@Value("${jwt.refresh-window:300000}")
private long refreshWindow;
/** Prefix of the known test/development secret - rejected in production. */
private static final String TEST_SECRET_PREFIX = "dGVzdC1zZWNyZXQta2V5";
/** Minimum secret key length in bytes (256 bits for HMAC-SHA256). */
private static final int MIN_SECRET_BYTES = 32;
/** Clock skew tolerance in seconds for JWT validation (ADR-0052 Phase 0). */
private static final long CLOCK_SKEW_SECONDS = 60;
/** JWT issuer claim - identifies this service as the token source. */
private static final String ISSUER = "contact-service";
/** JWT audience claim - identifies intended token consumers. */
private static final String AUDIENCE = "contact-service-api";
/** JWT claim name for the token fingerprint hash (ADR-0052 Phase C). */
public static final String FINGERPRINT_CLAIM = "fph";
/** JWT claim name for token usage (browser session vs programmatic API). */
public static final String TOKEN_USE_CLAIM = "token_use";
/**
* Validates JWT configuration at startup.
*
* <p>Enforces security requirements:
* <ul>
* <li>Secret must be configured (not null or blank)</li>
* <li>Test secrets are rejected in production profile</li>
* <li>Secret must be at least 256 bits (32 bytes) for HMAC-SHA256</li>
* </ul>
*
* @throws IllegalStateException if configuration is invalid
*/
@PostConstruct
void validateConfiguration() {
if (secretKey == null || secretKey.isBlank()) {
throw new IllegalStateException(
"jwt.secret must be configured. Generate with: openssl rand -base64 32");
}
// Reject known test secrets in production
if (secretKey.startsWith(TEST_SECRET_PREFIX)) {
final String profile = System.getProperty("spring.profiles.active", "");
if (profile.contains("prod")) {
throw new IllegalStateException(
"Test JWT secret detected in production profile. "
+ "Set JWT_SECRET environment variable with: openssl rand -base64 32");
}
}
// Validate minimum key length for HMAC-SHA256
final byte[] keyBytes = decodeSecretKey();
if (keyBytes.length < MIN_SECRET_BYTES) {
throw new IllegalStateException(
"jwt.secret must be at least 256 bits (32 bytes) for HMAC-SHA256. "
+ "Current length: " + keyBytes.length + " bytes. "
+ "Generate with: openssl rand -base64 32");
}
}
/**
* Decodes the secret key, handling both Base64 and legacy plain-text formats.
*/
private byte[] decodeSecretKey() {
try {
return Decoders.BASE64.decode(secretKey);
} catch (IllegalArgumentException | DecodingException ex) {
return secretKey.getBytes(StandardCharsets.UTF_8);
}
}
/**
* Extracts the username (subject) from a JWT token.
*
* @param token the JWT token
* @return the username stored in the token
*/
public String extractUsername(final String token) {
return extractClaim(token, Claims::getSubject);
}
/**
* Extracts a specific claim from the JWT token.
*
* @param token the JWT token
* @param claimsResolver function to extract the desired claim
* @param <T> the type of the claim
* @return the extracted claim value
*/
public <T> T extractClaim(final String token, final Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
/**
* Generates a JWT token with fingerprint binding (ADR-0052 Phase C).
*
* <p>When a fingerprint hash is provided, it is included in the token as the "fph" claim.
* The filter will verify this hash against the fingerprint cookie on each request.
*
* @param userDetails the user details
* @param fingerprintHash SHA-256 hash of the fingerprint cookie value, or null for no binding
* @return the generated JWT token
*/
public String generateToken(final UserDetails userDetails, final String fingerprintHash) {
return generateToken(new HashMap<>(), userDetails, fingerprintHash, TokenUse.SESSION);
}
/**
* Generates a JWT token with fingerprint binding and explicit token use.
*
* @param userDetails the user details
* @param fingerprintHash SHA-256 hash of the fingerprint cookie value, or null for no binding
* @param tokenUse intended token usage (session or api)
* @return the generated JWT token
*/
public String generateToken(
final UserDetails userDetails,
final String fingerprintHash,
final TokenUse tokenUse
) {
return generateToken(new HashMap<>(), userDetails, fingerprintHash, tokenUse);
}
/**
* Generates a JWT token with extra claims and fingerprint binding.
*
* @param extraClaims additional claims to include in the token
* @param userDetails the user details
* @param fingerprintHash SHA-256 hash of the fingerprint cookie value, or null for no binding
* @return the generated JWT token
*/
public String generateToken(
final Map<String, Object> extraClaims,
final UserDetails userDetails,
final String fingerprintHash
) {
return generateToken(extraClaims, userDetails, fingerprintHash, TokenUse.SESSION);
}
/**
* Generates a JWT token with extra claims, fingerprint binding, and explicit token use.
*
* @param extraClaims additional claims to include in the token
* @param userDetails the user details
* @param fingerprintHash SHA-256 hash of the fingerprint cookie value, or null for no binding
* @param tokenUse intended token usage (session or api)
* @return the generated JWT token
*/
public String generateToken(
final Map<String, Object> extraClaims,
final UserDetails userDetails,
final String fingerprintHash,
final TokenUse tokenUse
) {
final Map<String, Object> claims = new HashMap<>(extraClaims);
final TokenUse effectiveUse = tokenUse != null ? tokenUse : TokenUse.SESSION;
claims.put(TOKEN_USE_CLAIM, effectiveUse.claimValue());
if (fingerprintHash != null && !fingerprintHash.isEmpty()) {
claims.put(FINGERPRINT_CLAIM, fingerprintHash);
}
return buildToken(claims, userDetails, jwtExpiration);
}
/**
* Returns the configured token expiration time.
*
* @return expiration time in milliseconds
*/
public long getExpirationTime() {
return jwtExpiration;
}
/**
* Extracts the fingerprint hash from a JWT token.
*
* @param token the JWT token
* @return the fingerprint hash if present, or null if the token has no fingerprint claim
*/
public String extractFingerprintHash(final String token) {
return extractClaim(token, claims -> claims.get(FINGERPRINT_CLAIM, String.class));
}
/**
* Extracts the token usage claim ("session" or "api").
*
* @param token the JWT token
* @return the TokenUse, or null if not present or unknown
*/
public TokenUse extractTokenUse(final String token) {
final String claim = extractClaim(token, claims -> claims.get(TOKEN_USE_CLAIM, String.class));
return TokenUse.fromClaim(claim);
}
/**
* Validates a JWT token against user details.
*
* @param token the JWT token to validate
* @param userDetails the user details to validate against
* @return true if the token is valid for the user
*/
public boolean isTokenValid(final String token, final UserDetails userDetails) {
try {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
} catch (io.jsonwebtoken.ExpiredJwtException e) {
// Token is expired, so it's not valid
return false;
}
}
/**
* Checks if a token is eligible for refresh.
* A token is eligible if it's still valid OR expired within the refresh window.
*
* @param token the JWT token to check
* @param userDetails the user details to validate against
* @return true if the token can be refreshed
*/
public boolean isTokenEligibleForRefresh(final String token, final UserDetails userDetails) {
try {
final String username = extractUsername(token);
if (!username.equals(userDetails.getUsername())) {
return false;
}
final Date expiration = extractExpiration(token);
final long now = System.currentTimeMillis();
final long expirationTime = expiration.getTime();
// Token is valid OR expired within the refresh window
return expirationTime > now || (now - expirationTime) <= refreshWindow;
} catch (io.jsonwebtoken.ExpiredJwtException e) {
// Token is expired - check if within refresh window
final Claims claims = e.getClaims();
if (claims == null) {
return false;
}
final String username = claims.getSubject();
if (!username.equals(userDetails.getUsername())) {
return false;
}
final Date expiration = claims.getExpiration();
final long now = System.currentTimeMillis();
final long expirationTime = expiration.getTime();
return (now - expirationTime) <= refreshWindow;
}
}
/**
* Returns the configured refresh window.
*
* @return refresh window in milliseconds
*/
public long getRefreshWindow() {
return refreshWindow;
}
private String buildToken(
final Map<String, Object> extraClaims,
final UserDetails userDetails,
final long expiration
) {
return Jwts.builder()
.id(UUID.randomUUID().toString())
.issuer(ISSUER)
.audience().add(AUDIENCE).and()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey())
.compact();
}
/**
* Extracts the unique token identifier (JTI) from a JWT.
*
* <p>The JTI can be used for token revocation by maintaining a blocklist
* of revoked token IDs.
*
* @param token the JWT token
* @return the token's unique identifier
*/
public String extractTokenId(final String token) {
return extractClaim(token, Claims::getId);
}
private boolean isTokenExpired(final String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(final String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(final String token) {
return Jwts.parser()
.verifyWith(getSignInKey())
.clockSkewSeconds(CLOCK_SKEW_SECONDS)
.requireIssuer(ISSUER)
.requireAudience(AUDIENCE)
.build()
.parseSignedClaims(token)
.getPayload();
}
private SecretKey getSignInKey() {
byte[] keyBytes;
try {
keyBytes = Decoders.BASE64.decode(secretKey);
} catch (IllegalArgumentException | DecodingException ex) {
// Fallback for legacy plain-text secrets (documented as discouraged)
keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
}
return Keys.hmacShaKeyFor(keyBytes);
}
}