Skip to content

Commit 8eeedc2

Browse files
authored
Use token profile and delete check users (#140)
Signed-off-by: achour94 <[email protected]>
1 parent 5a1b289 commit 8eeedc2

File tree

10 files changed

+322
-61
lines changed

10 files changed

+322
-61
lines changed

src/main/java/org/gridsuite/gateway/GatewayConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public class GatewayConfig {
2626
public static final String END_POINT_SERVICE_NAME = "end_point_service_name";
2727

2828
public static final String HEADER_USER_ID = "userId";
29-
public static final String HEADER_CLIENT_ID = "clientId";
29+
public static final String HEADER_ROLES = "roles";
3030

3131
@Bean
3232
public RouteLocator myRoutes(RouteLocatorBuilder builder, ApplicationContext context) {

src/main/java/org/gridsuite/gateway/filters/AbstractGlobalPreFilter.java

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,68 @@
66
*/
77
package org.gridsuite.gateway.filters;
88

9+
import org.gridsuite.gateway.services.UserAdminService;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
912
import org.springframework.cloud.gateway.filter.GlobalFilter;
1013
import org.springframework.core.Ordered;
1114
import org.springframework.http.HttpHeaders;
1215
import org.springframework.http.HttpStatus;
1316
import org.springframework.web.server.ServerWebExchange;
1417
import reactor.core.publisher.Mono;
1518

19+
import java.util.List;
20+
21+
import static org.gridsuite.gateway.GatewayConfig.HEADER_USER_ID;
22+
1623
/**
1724
* @author Slimane Amar <slimane.amar at rte-france.com>
1825
*/
1926
public abstract class AbstractGlobalPreFilter implements GlobalFilter, Ordered {
2027

21-
protected Mono<Void> completeWithCode(ServerWebExchange exchange, HttpStatus code) {
22-
exchange.getResponse().setStatusCode(code);
28+
private static final Logger LOGGER = LoggerFactory.getLogger(AbstractGlobalPreFilter.class);
29+
30+
protected final UserAdminService userAdminService;
31+
32+
protected AbstractGlobalPreFilter(UserAdminService userAdminService) {
33+
this.userAdminService = userAdminService;
34+
}
35+
36+
/**
37+
* Completes the exchange with the specified HTTP error status code and records failed connection attempts.
38+
*
39+
* IMPORTANT NOTE: This method is intended only for authentication or authorization failures
40+
* in the filter chain. It records a failed connection attempt (isConnectionAccepted=false)
41+
* to track unsuccessful login attempts. If called with non-error status codes, an
42+
* IllegalArgumentException will be thrown.
43+
*
44+
* @param exchange The server web exchange
45+
* @param status The HTTP error status to return to the client
46+
* @return A Mono that completes when the response has been sent
47+
* @throws IllegalArgumentException if called with a non-error status code
48+
*/
49+
protected Mono<Void> completeWithError(ServerWebExchange exchange, HttpStatus status) {
50+
// Ensure we're only using this method with error status codes
51+
if (!status.isError()) {
52+
LOGGER.warn("completeWithError was called with a non-error status code: {}. " +
53+
"This method is intended for error responses only.", status);
54+
}
55+
56+
exchange.getResponse().setStatusCode(status);
2357
if ("websocket".equalsIgnoreCase(exchange.getRequest().getHeaders().getUpgrade())) {
2458
// Force the connection to close for websockets handshakes to workaround apache
2559
// httpd reusing the connection for all subsequent requests in this connection.
2660
exchange.getResponse().getHeaders().set(HttpHeaders.CONNECTION, "close");
2761
}
62+
63+
// Record failed connection attempt if user ID is present
64+
HttpHeaders httpHeaders = exchange.getRequest().getHeaders();
65+
List<String> maybeSubList = httpHeaders.get(HEADER_USER_ID);
66+
67+
if (maybeSubList != null && !maybeSubList.isEmpty()) {
68+
String sub = maybeSubList.getFirst();
69+
userAdminService.userRecordConnection(sub, false).subscribe();
70+
}
2871
return exchange.getResponse().setComplete();
2972
}
3073

src/main/java/org/gridsuite/gateway/filters/SupervisionAccessControlFilter.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77
package org.gridsuite.gateway.filters;
88

9+
import org.gridsuite.gateway.services.UserAdminService;
910
import org.slf4j.Logger;
1011
import org.slf4j.LoggerFactory;
1112
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
@@ -45,13 +46,17 @@ public class SupervisionAccessControlFilter extends AbstractGlobalPreFilter {
4546
private static final Pattern SUPERVISION_PATTERN = Pattern.compile("^/v\\d+/supervision(/.*)?$");
4647
public static final String ACCESS_TO_SUPERVISION_ENDPOINT_IS_NOT_ALLOWED = "{}: 403 Forbidden, Access to supervision endpoint is not allowed";
4748

49+
public SupervisionAccessControlFilter(UserAdminService userAdminService) {
50+
super(userAdminService);
51+
}
52+
4853
@Override
4954
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
5055
String path = exchange.getRequest().getURI().getPath();
5156
if (SUPERVISION_PATTERN.matcher(path).matches()) {
5257
LOGGER.info(ACCESS_TO_SUPERVISION_ENDPOINT_IS_NOT_ALLOWED,
5358
exchange.getRequest().getPath());
54-
return completeWithCode(exchange, HttpStatus.FORBIDDEN);
59+
return completeWithError(exchange, HttpStatus.FORBIDDEN);
5560
}
5661

5762
return chain.filter(exchange);

src/main/java/org/gridsuite/gateway/filters/TokenValidatorGlobalPreFilter.java

Lines changed: 116 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import lombok.Getter;
2121
import org.gridsuite.gateway.GatewayService;
2222
import org.gridsuite.gateway.dto.TokenIntrospection;
23+
import org.gridsuite.gateway.services.UserAdminService;
2324
import org.gridsuite.gateway.services.UserIdentityService;
2425
import org.slf4j.Logger;
2526
import org.slf4j.LoggerFactory;
@@ -39,11 +40,9 @@
3940
import java.util.Map;
4041
import java.util.concurrent.ConcurrentHashMap;
4142

43+
import static org.gridsuite.gateway.GatewayConfig.HEADER_ROLES;
4244
import static org.gridsuite.gateway.GatewayConfig.HEADER_USER_ID;
4345

44-
//TODO add client_id
45-
//import static org.gridsuite.gateway.GatewayConfig.HEADER_CLIENT_ID;
46-
4746
/**
4847
* @author Chamseddine Benhamed <chamseddine.benhamed at rte-france.com>
4948
*/
@@ -52,6 +51,8 @@ public class TokenValidatorGlobalPreFilter extends AbstractGlobalPreFilter {
5251

5352
public static final String UNAUTHORIZED_INVALID_PLAIN_JOSE_OBJECT_ENCODING = "{}: 401 Unauthorized, Invalid plain JOSE object encoding or inactive opaque token";
5453
public static final String PARSING_ERROR = "{}: 500 Internal Server Error, error has been reached unexpectedly while parsing";
54+
public static final String UNAUTHORIZED_AUDIENCE_NOT_ALLOWED = "401 Unauthorized, {} Audience is not in the audiences white list";
55+
public static final String UNAUTHORIZED_CLIENT_NOT_ALLOWED = "401 Unauthorized, {} Client ID is not in the allowed clients list";
5556

5657
private static final Logger LOGGER = LoggerFactory.getLogger(TokenValidatorGlobalPreFilter.class);
5758
public static final String UNAUTHORIZED_THE_TOKEN_CANNOT_BE_TRUSTED = "{}: 401 Unauthorized, The token cannot be trusted";
@@ -62,12 +63,19 @@ public class TokenValidatorGlobalPreFilter extends AbstractGlobalPreFilter {
6263
@Value("${allowed-issuers}")
6364
private List<String> allowedIssuers;
6465

66+
@Value("${allowed-audiences:}")
67+
private List<String> allowedAudiences;
68+
69+
@Value("${allowed-clients:}")
70+
private List<String> allowedClients;
71+
6572
@Value("${storeIdToken:false}")
6673
private boolean storeIdTokens;
6774

6875
private Map<String, JWKSet> jwkSetCache = new ConcurrentHashMap<>();
6976

70-
public TokenValidatorGlobalPreFilter(GatewayService gatewayService, UserIdentityService userIdentityService) {
77+
public TokenValidatorGlobalPreFilter(GatewayService gatewayService, UserIdentityService userIdentityService, UserAdminService userAdminService) {
78+
super(userAdminService);
7179
this.gatewayService = gatewayService;
7280
this.userIdentityService = userIdentityService;
7381
}
@@ -88,7 +96,7 @@ public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
8896
if (ls == null && queryls == null) {
8997
LOGGER.info("{}: 401 Unauthorized, Authorization header or access_token query parameter is required",
9098
exchange.getRequest().getPath());
91-
return completeWithCode(exchange, HttpStatus.UNAUTHORIZED);
99+
return completeWithError(exchange, HttpStatus.UNAUTHORIZED);
92100
}
93101

94102
// For now we only handle one token. If needed, we can adapt this code to check
@@ -101,7 +109,7 @@ public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
101109
if (arr.size() != 2 || !arr.get(0).equals("Bearer")) {
102110
LOGGER.info("{}: 400 Bad Request, incorrect Authorization header value",
103111
exchange.getRequest().getPath());
104-
return completeWithCode(exchange, HttpStatus.BAD_REQUEST);
112+
return completeWithError(exchange, HttpStatus.BAD_REQUEST);
105113
}
106114

107115
token = arr.get(1);
@@ -118,11 +126,23 @@ public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
118126
LOGGER.debug("checking issuer");
119127
if (allowedIssuers.stream().noneMatch(iss -> jwtClaimsSet.getIssuer().startsWith(iss))) {
120128
LOGGER.info("{}: 401 Unauthorized, {} Issuer is not in the issuers white list", exchange.getRequest().getPath(), jwtClaimsSet.getIssuer());
121-
return completeWithCode(exchange, HttpStatus.UNAUTHORIZED);
129+
return completeWithError(exchange, HttpStatus.UNAUTHORIZED);
130+
}
131+
132+
if (!isValidAudienceOrClientId(jwtClaimsSet)) {
133+
return completeWithError(exchange, HttpStatus.UNAUTHORIZED);
122134
}
123135

124136
Issuer iss = new Issuer(jwtClaimsSet.getIssuer());
125-
ClientID clientID = new ClientID(jwtClaimsSet.getAudience().get(0));
137+
ClientID clientID;
138+
List<String> audiences = jwtClaimsSet.getAudience();
139+
if (audiences != null && !audiences.isEmpty()) {
140+
clientID = new ClientID(audiences.getFirst());
141+
} else {
142+
// Since audience validation failed but we're here, we must have a valid client_id
143+
String clientIdClaim = (String) jwtClaimsSet.getClaim("client_id");
144+
clientID = new ClientID(clientIdClaim);
145+
}
126146

127147
JWSAlgorithm jwsAlg = JWSAlgorithm.parse(jwt.getHeader().getAlgorithm().getName());
128148
return proceedFilter(new FilterInfos(exchange, chain, jwt, jwtClaimsSet, iss, clientID, jwsAlg));
@@ -142,6 +162,13 @@ private Mono<Void> validateOpaqueReferenceToken(String issBaseUri, String token,
142162
return gatewayService.getOpaqueTokenIntrospectionUri(issBaseUri)
143163
.flatMap(uri -> gatewayService.getOpaqueTokenIntrospection(uri, token))
144164
.flatMap((TokenIntrospection tokenIntrospection) -> {
165+
// Check client ID against allowedClients
166+
String clientId = tokenIntrospection.getClientId();
167+
if (!isValidClientId(clientId)) {
168+
LOGGER.info(UNAUTHORIZED_CLIENT_NOT_ALLOWED, clientId);
169+
return completeWithError(exchange, HttpStatus.UNAUTHORIZED);
170+
}
171+
145172
// TODO really add the client_id header instead of userid
146173
exchange.getRequest().mutate()
147174
.headers(h -> h.set(HEADER_USER_ID, tokenIntrospection.getClientId()));
@@ -150,15 +177,84 @@ private Mono<Void> validateOpaqueReferenceToken(String issBaseUri, String token,
150177
return chain.filter(exchange);
151178
} else {
152179
LOGGER.info(UNAUTHORIZED_INVALID_PLAIN_JOSE_OBJECT_ENCODING, exchange.getRequest().getPath());
153-
return completeWithCode(exchange, HttpStatus.UNAUTHORIZED);
180+
return completeWithError(exchange, HttpStatus.UNAUTHORIZED);
154181
}
155182
});
156183
}
157184

185+
/**
186+
* Validates a token's audience or client ID depending on token type.
187+
*
188+
* JWT ID tokens (representing end users) typically contain an 'aud' claim that needs validation.
189+
* JWT access tokens may not have an 'aud' claim but instead use a 'client_id' claim.
190+
*
191+
* We first try to validate the audience. Only if no valid audience is found, we fall back to
192+
* client ID validation. This allows both token types to work with the gateway.
193+
*
194+
* IMPORTANT NOTES:
195+
* - Currently, we only allow GridSuite audience tokens in the allowedAudiences configuration
196+
* - If we want to allow other frontend applications to access this API:
197+
* a) This validation logic needs to be reviewed and possibly expanded
198+
* b) The CORS strategy would need to be modified accordingly to allow those origins
199+
*
200+
* @param jwtClaimsSet The JWT claims set
201+
* @return true if validation passes, false otherwise
202+
*/
203+
private boolean isValidAudienceOrClientId(JWTClaimsSet jwtClaimsSet) {
204+
if (allowedAudiences.isEmpty() && allowedClients.isEmpty()) {
205+
LOGGER.debug("Bypassing audience and client ID validation as both allowed lists are empty");
206+
return true;
207+
}
208+
209+
LOGGER.debug("checking audience or client ID");
210+
List<String> tokenAudiences = jwtClaimsSet.getAudience();
211+
if (tokenAudiences != null && !tokenAudiences.isEmpty()) {
212+
boolean audienceMatched = tokenAudiences.stream().anyMatch(aud -> allowedAudiences.contains(aud));
213+
if (audienceMatched) {
214+
LOGGER.debug("Audience validation successful");
215+
return true;
216+
}
217+
LOGGER.info(UNAUTHORIZED_AUDIENCE_NOT_ALLOWED, tokenAudiences);
218+
return false;
219+
}
220+
// If there is no audience at all in the token we can try a fallback
221+
LOGGER.debug("Audience validation failed for audiences: {}, trying client ID as fallback", tokenAudiences);
222+
String clientIdClaim = (String) jwtClaimsSet.getClaim("client_id");
223+
if (isValidClientId(clientIdClaim)) {
224+
LOGGER.debug("Client ID validation successful");
225+
return true;
226+
}
227+
LOGGER.info(UNAUTHORIZED_CLIENT_NOT_ALLOWED, jwtClaimsSet.getClaim("client_id"));
228+
return false;
229+
}
230+
231+
/**
232+
* Validates whether a client ID is in the list of allowed clients.
233+
*
234+
* @param clientId The client ID to validate
235+
* @return true if validation passes, false otherwise
236+
*/
237+
private boolean isValidClientId(String clientId) {
238+
if (allowedClients.isEmpty()) {
239+
return true;
240+
}
241+
return clientId != null && allowedClients.contains(clientId);
242+
}
243+
158244
private Mono<Void> validate(FilterInfos filterInfos, JWKSet jwkset) throws BadJOSEException, JOSEException {
159245

160246
// Create validator for signed ID tokens
161-
// this works with jwt access tokens too (by chance ?) Do we need to modify this ?
247+
// IMPORTANT: IDTokenValidator strictly enforces OpenID Connect standards including
248+
// the mandatory presence of the 'aud' (audience) claim. Even though our code has a
249+
// fallback mechanism in isValidAudienceOrClientId() to validate tokens with only
250+
// client_id claims, the IDTokenValidator will still throw BadJOSEException with
251+
// message "Missing JWT audience (aud) claim" for any token without an audience.
252+
//
253+
// This creates a behavior inconsistency: tokens with valid client_id but no audience
254+
// will pass our custom validation (isValidAudienceOrClientId) but will be rejected here.
255+
//
256+
// Alternative approaches if client_id-only tokens must be fully supported:
257+
// Use DefaultJWTClaimsVerifier instead of IDTokenValidator (https://connect2id.com/products/nimbus-jose-jwt/examples/validating-jwt-access-tokens)
162258
IDTokenValidator validator = new IDTokenValidator(filterInfos.getIss(), filterInfos.getClientID(), filterInfos.getJwsAlg(), jwkset);
163259

164260
validator.validate(filterInfos.getJwt(), null);
@@ -183,7 +279,14 @@ private Mono<Void> validate(FilterInfos filterInfos, JWKSet jwkset) throws BadJO
183279
//we add the subject header
184280
filterInfos.getExchange().getRequest()
185281
.mutate()
186-
.headers(h -> h.set(HEADER_USER_ID, filterInfos.getJwtClaimsSet().getSubject()));
282+
.headers(h -> {
283+
h.set(HEADER_USER_ID, filterInfos.getJwtClaimsSet().getSubject());
284+
// Extract the profile claim if it exists and add it as roles header
285+
Object profileClaim = filterInfos.getJwtClaimsSet().getClaim("profile");
286+
if (profileClaim != null) {
287+
h.set(HEADER_ROLES, profileClaim.toString());
288+
}
289+
});
187290

188291
return filterInfos.getChain().filter(filterInfos.getExchange());
189292
}
@@ -216,7 +319,7 @@ Mono<Void> proceedFilter(FilterInfos filterInfos) {
216319
jwkSetCache.put(filterInfos.getIss().getValue(), jwkSet);
217320
} catch (ParseException e) {
218321
LOGGER.info(PARSING_ERROR, filterInfos.getExchange().getRequest().getPath());
219-
return completeWithCode(filterInfos.getExchange(), HttpStatus.INTERNAL_SERVER_ERROR);
322+
return completeWithError(filterInfos.getExchange(), HttpStatus.INTERNAL_SERVER_ERROR);
220323
}
221324
return tryValidate(filterInfos, jwkSet, false);
222325
})
@@ -234,7 +337,7 @@ private Mono<Void> tryValidate(FilterInfos filterInfos, JWKSet jwksCache, boolea
234337
return this.proceedFilter(filterInfos);
235338
} else {
236339
LOGGER.info(UNAUTHORIZED_THE_TOKEN_CANNOT_BE_TRUSTED, filterInfos.getExchange().getRequest().getPath());
237-
return completeWithCode(filterInfos.getExchange(), HttpStatus.UNAUTHORIZED);
340+
return completeWithError(filterInfos.getExchange(), HttpStatus.UNAUTHORIZED);
238341
}
239342
}
240343
}

src/main/java/org/gridsuite/gateway/filters/UserAdminControlGlobalPreFilter.java

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,21 @@
2121
import java.util.List;
2222

2323
import static org.gridsuite.gateway.GatewayConfig.HEADER_USER_ID;
24-
import static org.gridsuite.gateway.GatewayConfig.HEADER_CLIENT_ID;
2524

2625
/**
2726
* @author Etienne Homer <etienne.homer at rte-france.com>
27+
*
28+
* This filter executes after other filters in the chain (as determined by its order),
29+
* allowing other filters to reject invalid requests before reaching this point.
30+
* The primary purpose of this filter is to record successful user connections rather than
31+
* to reject requests - except missing user ID which results in UNAUTHORIZED.
2832
*/
2933
@Component
3034
public class UserAdminControlGlobalPreFilter extends AbstractGlobalPreFilter {
3135
private static final Logger LOGGER = LoggerFactory.getLogger(UserAdminControlGlobalPreFilter.class);
3236

33-
private UserAdminService userAdminService;
34-
3537
public UserAdminControlGlobalPreFilter(UserAdminService userAdminService) {
36-
this.userAdminService = userAdminService;
38+
super(userAdminService);
3739
}
3840

3941
@Override
@@ -42,21 +44,16 @@ public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull GatewayFi
4244

4345
HttpHeaders httpHeaders = exchange.getRequest().getHeaders();
4446
List<String> maybeSubList = httpHeaders.get(HEADER_USER_ID);
45-
List<String> maybeClientIdList = httpHeaders.get(HEADER_CLIENT_ID);
4647

4748
if (maybeSubList != null) {
4849
String sub = maybeSubList.get(0);
49-
return userAdminService.userExists(sub).flatMap(userExist -> Boolean.TRUE.equals(userExist) ? chain.filter(exchange) : completeWithCode(exchange, HttpStatus.FORBIDDEN));
50-
}
51-
52-
if (maybeClientIdList != null) {
53-
// String clientId = maybeClientId.get(0);
54-
// TODO do something with clientId
50+
// Record the connection with isConnectionAccepted=true
51+
// and continue with the filter chain regardless of the result
52+
userAdminService.userRecordConnection(sub, true).subscribe();
5553
return chain.filter(exchange);
5654
}
5755

58-
// no sub or no clientid, can't control access
59-
return completeWithCode(exchange, HttpStatus.UNAUTHORIZED);
56+
return completeWithError(exchange, HttpStatus.UNAUTHORIZED);
6057
}
6158

6259
@Override

0 commit comments

Comments
 (0)