Skip to content

Commit f918916

Browse files
feat: Refresh endpoint for prolonging valid JWT token (#1719)
* Add refresh endpoint Signed-off-by: jandadav <janda.david@gmail.com> * Add conditional enablement Signed-off-by: jandadav <janda.david@gmail.com> * Add integration test for refresh Signed-off-by: jandadav <janda.david@gmail.com> * checkstyle Signed-off-by: jandadav <janda.david@gmail.com> * enable token refresh for containers builds Signed-off-by: jandadav <janda.david@gmail.com> * Document endpoint Signed-off-by: jandadav <janda.david@gmail.com> * Review points Signed-off-by: jandadav <janda.david@gmail.com> * Test with enabled refresh endpoint Signed-off-by: jandadav <janda.david@gmail.com> Co-authored-by: Jakub Balhar <jakub@balhar.net>
1 parent 77c30b1 commit f918916

File tree

10 files changed

+361
-10
lines changed

10 files changed

+361
-10
lines changed

apiml-security-common/src/main/java/org/zowe/apiml/security/common/config/AuthConfigurationProperties.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public class AuthConfigurationProperties {
4242
private String gatewayQueryEndpointOldFormat = "/api/v1/gateway/auth/query";
4343
private String gatewayTicketEndpointOldFormat = "/api/v1/gateway/auth/ticket";
4444

45+
private String gatewayRefreshEndpointOldFormat = "/api/v1/gateway/auth/refresh";
46+
private String gatewayRefreshEndpointNewFormat = "/gateway/api/v1/auth/refresh";
47+
4548
private String serviceLoginEndpoint = "/auth/login";
4649
private String serviceLogoutEndpoint = "/auth/logout";
4750

config/docker/gateway-service.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ apiml:
33
hostname: gateway-service
44
discoveryServiceUrls: https://discovery-service:10011/eureka/
55
security:
6+
allowTokenRefresh: true
67
auth:
78
zosmf:
89
serviceId: mockzosmf # Replace me with the correct z/OSMF service id

gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,18 @@
3333
import org.springframework.security.web.authentication.logout.LogoutHandler;
3434
import org.springframework.security.web.firewall.StrictHttpFirewall;
3535
import org.springframework.security.web.util.matcher.RegexRequestMatcher;
36-
import org.zowe.apiml.filter.SecureConnectionFilter;
3736
import org.zowe.apiml.filter.AttlsFilter;
37+
import org.zowe.apiml.filter.SecureConnectionFilter;
3838
import org.zowe.apiml.gateway.controllers.AuthController;
3939
import org.zowe.apiml.gateway.controllers.CacheServiceController;
4040
import org.zowe.apiml.gateway.error.InternalServerErrorController;
4141
import org.zowe.apiml.gateway.security.login.x509.X509AuthenticationProvider;
42-
import org.zowe.apiml.gateway.security.query.QueryFilter;
43-
import org.zowe.apiml.gateway.security.query.SuccessfulQueryHandler;
44-
import org.zowe.apiml.gateway.security.query.TokenAuthenticationProvider;
42+
import org.zowe.apiml.gateway.security.query.*;
43+
import org.zowe.apiml.gateway.security.refresh.SuccessfulRefreshHandler;
4544
import org.zowe.apiml.gateway.security.service.AuthenticationService;
4645
import org.zowe.apiml.gateway.security.ticket.SuccessfulTicketHandler;
4746
import org.zowe.apiml.gateway.services.ServicesInfoController;
48-
import org.zowe.apiml.security.common.config.AuthConfigurationProperties;
49-
import org.zowe.apiml.security.common.config.CertificateAuthenticationProvider;
50-
import org.zowe.apiml.security.common.config.HandlerInitializer;
47+
import org.zowe.apiml.security.common.config.*;
5148
import org.zowe.apiml.security.common.content.BasicContentFilter;
5249
import org.zowe.apiml.security.common.content.CookieContentFilter;
5350
import org.zowe.apiml.security.common.filter.ApimlX509Filter;
@@ -86,6 +83,7 @@ public class NewSecurityConfiguration {
8683
private final HandlerInitializer handlerInitializer;
8784
private final SuccessfulQueryHandler successfulQueryHandler;
8885
private final SuccessfulTicketHandler successfulTicketHandler;
86+
private final SuccessfulRefreshHandler successfulRefreshHandler;
8987
@Qualifier("publicKeyCertificatesBase64")
9088
private final Set<String> publicKeyCertificatesBase64;
9189
private final X509AuthenticationProvider x509AuthenticationProvider;
@@ -178,7 +176,7 @@ private LogoutHandler logoutHandler() {
178176
}
179177

180178
/**
181-
* Query and Ticket endpoints share single filter that handles auth with and without certificate. This logic is encapsulated in the queryFilter or ticketFilter.
179+
* Query and Ticket and Refresh endpoints share single filter that handles auth with and without certificate. This logic is encapsulated in the queryFilter or ticketFilter.
182180
* Query endpoint does not require certificate to be present in RequestContext. It verifies JWT token.
183181
*/
184182
@Configuration
@@ -218,7 +216,7 @@ private QueryFilter queryFilter(String queryEndpoint, AuthenticationManager auth
218216
}
219217

220218
/**
221-
* Query and Ticket endpoints share single filter that handles auth with and without certificate. This logic is encapsulated in the queryFilter or ticketFilter.
219+
* Query and Ticket and Refresh endpoints share single filter that handles auth with and without certificate. This logic is encapsulated in the queryFilter or ticketFilter.
222220
* Ticket endpoint does require certificate to be present in RequestContext. It verifies the JWT token.
223221
*/
224222

@@ -262,6 +260,52 @@ private QueryFilter ticketFilter(String ticketEndpoint, AuthenticationManager au
262260
}
263261
}
264262

263+
/**
264+
* Query and Ticket and Refresh endpoints share single filter that handles auth with and without certificate.
265+
* Refresh endpoint does require certificate to be present in RequestContext. It verifies the JWT token and based
266+
* on valid token and client certificate issues a new valid token
267+
*/
268+
@Configuration
269+
@RequiredArgsConstructor
270+
@ConditionalOnProperty(name = "apiml.security.allowTokenRefresh", havingValue = "true", matchIfMissing = false)
271+
@Order(6)
272+
class Refresh extends WebSecurityConfigurerAdapter {
273+
274+
private final AuthenticationProvider tokenAuthenticationProvider;
275+
276+
@Override
277+
protected void configure(AuthenticationManagerBuilder auth) {
278+
auth.authenticationProvider(tokenAuthenticationProvider); // for authenticating Tokens
279+
}
280+
281+
@Override
282+
protected void configure(HttpSecurity http) throws Exception {
283+
baseConfigure(http.requestMatchers().antMatchers(
284+
authConfigurationProperties.getGatewayRefreshEndpointNewFormat(),
285+
authConfigurationProperties.getGatewayRefreshEndpointOldFormat()
286+
).and()).authorizeRequests()
287+
.anyRequest().authenticated()
288+
.and()
289+
.logout().disable() // logout filter in this chain not needed
290+
.x509() //default x509 filter, authenticates trusted cert, ticketFilter(..) depends on this
291+
.subjectPrincipalRegex(EXTRACT_USER_PRINCIPAL_FROM_COMMON_NAME)
292+
.userDetailsService(new SimpleUserDetailService())
293+
.and()
294+
.addFilterBefore(refreshFilter("/**", authenticationManager()), UsernamePasswordAuthenticationFilter.class);
295+
}
296+
297+
private QueryFilter refreshFilter(String ticketEndpoint, AuthenticationManager authenticationManager) {
298+
return new QueryFilter(
299+
ticketEndpoint,
300+
successfulRefreshHandler,
301+
handlerInitializer.getAuthenticationFailureHandler(),
302+
authenticationService,
303+
HttpMethod.POST,
304+
true,
305+
authenticationManager);
306+
}
307+
}
308+
265309
/**
266310
* Endpoints which are protected by client certificate
267311
* Default Spring security x509 filter authenticates any trusted certificate
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* This program and the accompanying materials are made available under the terms of the
3+
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
4+
* https://www.eclipse.org/legal/epl-v20.html
5+
*
6+
* SPDX-License-Identifier: EPL-2.0
7+
*
8+
* Copyright Contributors to the Zowe Project.
9+
*/
10+
11+
package org.zowe.apiml.gateway.security.refresh;
12+
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.security.core.Authentication;
16+
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
17+
import org.springframework.stereotype.Component;
18+
import org.zowe.apiml.gateway.security.service.AuthenticationService;
19+
import org.zowe.apiml.gateway.security.service.TokenCreationService;
20+
import org.zowe.apiml.security.common.config.AuthConfigurationProperties;
21+
import org.zowe.apiml.security.common.token.TokenAuthentication;
22+
import org.zowe.apiml.util.CookieUtil;
23+
24+
import javax.servlet.ServletException;
25+
import javax.servlet.http.HttpServletRequest;
26+
import javax.servlet.http.HttpServletResponse;
27+
import java.io.IOException;
28+
29+
30+
/**
31+
* Handler for refreshing the issued JWT token
32+
* The handler gets authenticated TokenAuthentication with previous token in credentials
33+
* It invalidates the previous token and issues a fresh one
34+
*/
35+
@Component
36+
@RequiredArgsConstructor
37+
public class SuccessfulRefreshHandler implements AuthenticationSuccessHandler {
38+
39+
private final AuthConfigurationProperties authConfigurationProperties;
40+
private final AuthenticationService authenticationService;
41+
private final TokenCreationService tokenCreationService;
42+
43+
@Override
44+
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
45+
if (authentication instanceof TokenAuthentication) {
46+
TokenAuthentication tokenAuth = (TokenAuthentication) authentication;
47+
48+
authenticationService.invalidateJwtToken(tokenAuth.getCredentials(), true);
49+
String jwtToken = tokenCreationService.createJwtTokenWithoutCredentials(tokenAuth.getPrincipal());
50+
setCookie(jwtToken, response);
51+
}
52+
response.setStatus(HttpStatus.NO_CONTENT.value());
53+
}
54+
55+
private void setCookie(String token, HttpServletResponse response) {
56+
AuthConfigurationProperties.CookieProperties cp = authConfigurationProperties.getCookieProperties();
57+
String cookieHeader = CookieUtil.setCookieHeader(
58+
cp.getCookieName(),
59+
token,
60+
cp.getCookieComment(),
61+
cp.getCookiePath(),
62+
cp.getCookieSameSite().getValue(),
63+
cp.getCookieMaxAge(),
64+
true,
65+
cp.isCookieSecure()
66+
);
67+
68+
response.addHeader("Set-Cookie", cookieHeader);
69+
}
70+
}

gateway-service/src/main/resources/application.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ apiml:
5656
security:
5757
filterChainConfiguration: new
5858
headersToBeCleared: X-Certificate-Public,X-Certificate-DistinguishedName,X-Certificate-CommonName
59+
allowTokenRefresh: false
5960
auth:
6061
provider: zosmf
6162
jwtKeyAlias: jwtsecret

gateway-service/src/main/resources/gateway-api-doc.json

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,53 @@
167167
}
168168
}
169169
},
170+
171+
"/auth/refresh": {
172+
"post": {
173+
"tags": ["Security"],
174+
"summary": "Refresh authentication token.",
175+
"description": "**Note:** This endpoint is disabled by default.\n\nUse the `/refresh` API to request a new JWT authentication token for the user associated with provided token.\nThe old token is invalidated and new token is issued with refreshed expiration time.\n\nThis endpoint is protect by a client certificate.\n\n**HTTP Headers:**\n\nThe ticket request requires the token in one of the following formats: \n * Cookie named `apimlAuthenticationToken`.\n * Bearer authentication\n \n*Header example:* Authorization: Bearer *token*",
176+
"operationId": "RefreshTokenUsingPOST",
177+
"security": [
178+
{
179+
"CookieAuth": []
180+
},
181+
{
182+
"Bearer": []
183+
}
184+
],
185+
"responses": {
186+
"204": {
187+
"description": "Authenticated",
188+
"headers": {
189+
"Set-Cookie": {
190+
"description": "Cookie named apimlAuthenticationToken contains authentication token.",
191+
"schema": {
192+
"type": "string"
193+
}
194+
}
195+
}
196+
},
197+
"401": {
198+
"description": "Zowe token is not provided, is invalid or is expired."
199+
},
200+
"403": {
201+
"description": "A client certificate is not provided or is expired."
202+
},
203+
"404": {
204+
"description": "Not Found. The endpoint is not enabled or not propperly configured"
205+
},
206+
"405": {
207+
"description": "Method Not Allowed"
208+
},
209+
"500": {
210+
"description": "Process of refreshing token has failed unexpectedly."
211+
}
212+
}
213+
}
214+
},
215+
216+
170217
"/auth/logout": {
171218
"post": {
172219
"tags": ["Security"],
@@ -333,7 +380,7 @@
333380
"tags": ["Services"],
334381
"summary": "Returns detailed information about the requested service",
335382
"description": "Use this endpoint to obtain detailed information about the service, its APIs, and its instances. This endpoint is protected by the `APIML.SERVICES` resource in the `ZOWE` class. At least `READ` access is required.",
336-
"operationId": "servicesUsingGET",
383+
"operationId": "servicesUsingGETSpecific",
337384
"parameters": [
338385
{
339386
"in": "path",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* This program and the accompanying materials are made available under the terms of the
3+
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
4+
* https://www.eclipse.org/legal/epl-v20.html
5+
*
6+
* SPDX-License-Identifier: EPL-2.0
7+
*
8+
* Copyright Contributors to the Zowe Project.
9+
*/
10+
11+
package org.zowe.apiml.gateway.security.refresh;
12+
13+
import org.junit.jupiter.api.*;
14+
import org.springframework.http.HttpHeaders;
15+
import org.springframework.http.HttpStatus;
16+
import org.springframework.mock.web.MockHttpServletRequest;
17+
import org.springframework.mock.web.MockHttpServletResponse;
18+
import org.springframework.security.authentication.TestingAuthenticationToken;
19+
import org.springframework.security.core.Authentication;
20+
import org.zowe.apiml.gateway.security.service.AuthenticationService;
21+
import org.zowe.apiml.gateway.security.service.TokenCreationService;
22+
import org.zowe.apiml.security.common.config.AuthConfigurationProperties;
23+
import org.zowe.apiml.security.common.token.TokenAuthentication;
24+
25+
import javax.servlet.ServletException;
26+
import javax.servlet.http.HttpServletRequest;
27+
import javax.servlet.http.HttpServletResponse;
28+
import java.io.IOException;
29+
30+
import static org.hamcrest.MatcherAssert.assertThat;
31+
import static org.hamcrest.Matchers.emptyOrNullString;
32+
import static org.hamcrest.Matchers.is;
33+
import static org.mockito.Mockito.*;
34+
35+
class SuccessfulRefreshHandlerTest {
36+
37+
AuthConfigurationProperties authConfigurationProperties = new AuthConfigurationProperties();
38+
AuthenticationService authenticationService = mock(AuthenticationService.class);
39+
TokenCreationService tokenCreationService = mock(TokenCreationService.class);
40+
SuccessfulRefreshHandler underTest = new SuccessfulRefreshHandler(authConfigurationProperties,
41+
authenticationService, tokenCreationService);
42+
HttpServletRequest request;
43+
HttpServletResponse response;
44+
45+
@BeforeEach
46+
void setUp() {
47+
request = new MockHttpServletRequest();
48+
response = new MockHttpServletResponse();
49+
when(tokenCreationService.createJwtTokenWithoutCredentials(anyString())).thenReturn("NEWTOKEN");
50+
}
51+
52+
@Nested
53+
class GivenAuthenticationInputs {
54+
@Test
55+
void unknownTypeOfAuthenticationDoesntDoAnything() throws ServletException, IOException {
56+
Authentication auth = new TestingAuthenticationToken("Principal", "credentials");
57+
underTest.onAuthenticationSuccess(request, response, auth);
58+
verify(authenticationService, never()).invalidateJwtToken("TOKEN", true);
59+
assertThat(response.getStatus(), is(HttpStatus.NO_CONTENT.value()));
60+
assertThat(response.getHeader(HttpHeaders.SET_COOKIE), is(emptyOrNullString()));
61+
}
62+
63+
@Test
64+
void tokenTypeOfAuthenticationIssuesToken() throws ServletException, IOException {
65+
Authentication auth = new TokenAuthentication("USER", "TOKEN");
66+
underTest.onAuthenticationSuccess(request, response, auth);
67+
verify(authenticationService, atLeastOnce()).invalidateJwtToken("TOKEN", true);
68+
assertThat(response.getStatus(), is(HttpStatus.NO_CONTENT.value()));
69+
assertThat(response.getHeader(HttpHeaders.SET_COOKIE),
70+
is("apimlAuthenticationToken=NEWTOKEN; Path=/; Secure; HttpOnly; SameSite=Strict"));
71+
}
72+
}
73+
74+
}

gateway-service/src/test/resources/application-test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ apiml:
3232
timeoutMillis: 30000 # Timeout for connection to the services
3333
security:
3434
filterChainConfiguration: new
35+
allowTokenRefresh: true
3536
ssl:
3637
verifySslCertificatesOfServices: true
3738
auth:

gateway-service/src/test/resources/application.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ apiml:
2929
timeoutMillis: 30000 # Timeout for connection to the services
3030
security:
3131
filterChainConfiguration: new
32+
allowTokenRefresh: true
3233
ssl:
3334
verifySslCertificatesOfServices: true
3435
x509:

0 commit comments

Comments
 (0)