Handling errors from Spring WebClient in another method - spring

In a Spring Boot application, I'm using WebClient to invoke a POST request to a remote application. The method currently looks like this:
// Class A
public void sendNotification(String notification) {
final WebClient webClient = WebClient.builder()
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.build();
webClient.post()
.uri("http://localhost:9000/api")
.body(BodyInserters.fromValue(notification))
.retrieve()
.onStatus(HttpStatus::isError, clientResponse -> Mono.error(NotificationException::new))
.toBodilessEntity()
.block();
log.info("Notification delivered successfully");
}
// Class B
public void someOtherMethod() {
sendNotification("test");
}
The use case is: A method in another class calls sendNotification and should handle any error, i.e. any non 2xx status or if the request couldn't even be sent.
But I'm struggling with the concept of handling errors in the WebClient. As far as I understood, the following line would catch any HTTP status other than 2xx/3xx and then return a Mono.error with the NotificationException (a custom exception extending Exception).
onStatus(HttpStatus::isError, clientResponse -> Mono.error(NotificationException::new))
But how could someOtherMethod() handle this error scenario? How could it process this Mono.error? Or how does it actually catch the NotificationException if sendNotification doesn't even throw it in the signature?

Well, there are many ways to handle errors, it really depends on what you want to do in case of an error.
In your current setup, the solution is straightforward: first, NotificationException should extend RuntimeException, thus, in case of an HTTP error, .block() will throw a NotificationException. It is a good practice to add it in the signature of the method, accompanied with a Javadoc entry.
In another method, you just need to catch the exception and do what you want with it.
/**
* #param notification
* #throws NotificationException in case of a HTTP error
*/
public void sendNotification(String notification) throws NotificationException {
final WebClient webClient = WebClient.builder()
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.build();
webClient.post()
.uri("http://localhost:9000/api")
.body(BodyInserters.fromValue(notification))
.retrieve()
.onStatus(HttpStatus::isError, clientResponse -> Mono.error(NotificationException::new))
.toBodilessEntity()
.block();
log.info("Notification delivered successfully");
}
public void someOtherMethod() {
try {
sendNotification("test");
} catch (NotificationException e) {
// Treat exception
}
}
In a more reactive style, you could return a Mono and use onErrorResume().
public Mono<Void> sendNotification(String notification) {
final WebClient webClient = WebClient.builder()
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.build();
return webClient.post()
.uri("http://localhost:9000/api")
.body(BodyInserters.fromValue(notification))
.retrieve()
.onStatus(HttpStatus::isError, clientResponse -> Mono.error(NotificationException::new))
.bodyToMono(Void.class);
}
public void someOtherMethod() {
sendNotification("test")
.onErrorResume(NotificationException.class, ex -> {
log.error(ex.getMessage());
return Mono.empty();
})
.doOnSuccess(unused -> log.info("Notification delivered successfully"))
.block();
}

Using imperative/blocking style you can surround it with a try-catch:
try {
webClient.post()
.uri("http://localhost:9000/api")
.body(BodyInserters.fromValue(notification))
.retrieve()
.onStatus(HttpStatus::isError, clientResponse -> Mono.error(NotificationException::new))
.toBodilessEntity()
.block();
} catch(NotificationException e) {...}
A reactive solution would be to use the onErrorResume operator like this:
webClient.post()
.uri("http://localhost:9000/api")
.body(BodyInserters.fromValue(notification))
.retrieve()
.onErrorResume(e -> someOtherMethod())
.toBodilessEntity();
Here, the reactive method someOtherMethod() will be executed in case of any error.

Related

Spring oauth2 authorization server: unable to logout users

I have created an angular app that serves as an oauth2 client. I have created my authorization server with spring oauth2 using the following security configs
#Bean
#Order(1)
public SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf().disable()
.headers().frameOptions().disable()
.and()
.antMatcher("/auth/account/**")
.authorizeRequests()
.anyRequest().authenticated()
.and()
.logout()
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.and()
.oauth2ResourceServer().jwt();
return http.build();
}
#Bean
#Order(2)
public SecurityFilterChain standardSecurityFilterChain(HttpSecurity http) throws Exception {
// #formatter:off
http
.addFilterBefore(corsFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/management/**").permitAll()
.antMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
.and()
.logout()
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.and()
.formLogin(withDefaults());
return http.build();
}
and here is my authorization server config
#Configuration
public class AuthServerConfig {
private final DataSource dataSource;
private final AuthProperties authProps;
private final PasswordEncoder encoder;
public AuthServerConfig(DataSource dataSource, AuthProperties authProps, PasswordEncoder encoder) {
this.dataSource = dataSource;
this.authProps = authProps;
this.encoder = encoder;
}
#Bean
public JdbcTemplate jdbcTemplate() {
return new JdbcTemplate(dataSource);
}
#Bean
#Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
return http.formLogin(Customizer.withDefaults()).build();
}
#Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
JdbcRegisteredClientRepository clientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
RegisteredClient webClient = RegisteredClient.withId("98a9104c-wertyuiop")
.clientId(authProps.getClientId())
.clientName(authProps.getClientName())
.clientSecret(encoder.encode(authProps.getClientSecret()))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:4200/xxxx/yyy")
.redirectUri("http://127.0.0.1:8000/xxxx/yyy")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("farmer:read")
.scope("farmer:write")
.tokenSettings(tokenSettings())
.build();
clientRepository.save(webClient);
return clientRepository;
}
#Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
#Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
#Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
private static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
#Bean
public ProviderSettings providerSettings() {
return ProviderSettings.builder()
.issuer(authProps.getIssuerUri())
.build();
}
#Bean
public TokenSettings tokenSettings() {
return TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofDays(1))
.refreshTokenTimeToLive(Duration.ofDays(1))
.build();
}
}
Here is my build.gradle file
plugins {
id 'org.springframework.boot' version '2.6.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'org.liquibase.gradle' version '2.1.0'
id 'java'
}
group = 'com.shamba.records'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
maven { url 'https://repo.spring.io/release' }
}
ext {
set('springCloudVersion', "2021.0.0")
set('liquibaseVersion', "4.6.1")
}
configurations {
liquibaseRuntime.extendsFrom runtimeClasspath
}
dependencies {
implementation 'tech.jhipster:jhipster-framework:7.4.0'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.security:spring-security-oauth2-authorization-server:0.2.1'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.security:spring-security-cas:5.6.1'
// mapstruct
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
// jackson
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hppc'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'org.zalando:problem-spring-web:0.26.0'
// configure liquibase
implementation "org.liquibase:liquibase-core:${liquibaseVersion}"
liquibaseRuntime 'org.liquibase:liquibase-groovy-dsl:3.0.0'
liquibaseRuntime 'info.picocli:picocli:4.6.1'
liquibaseRuntime 'org.postgresql:postgresql'
liquibaseRuntime group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.1'
liquibaseRuntime 'org.liquibase.ext:liquibase-hibernate5:3.6'
liquibaseRuntime sourceSets.main.output
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
test {
useJUnitPlatform()
}
and here is part of the properties, I have omitted other things because of brevity
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://${AUTH_SERVICE_HOST:127.0.0.1}:5000
jwk-set-uri: http://${AUTH_SERVICE_HOST:127.0.0.1}:5000/oauth2/jwks
I am able to sign in and sign out users using authorization code flow but the issue comes in after the first successful sign-in, when the users click on the sign in the user is automatically logged in by the auth server even after calling the /oauth2/revoke endpoint and specifying the logout configs below in the auth server
.and()
.logout()
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
I also tried to implement a custom endpoint /auth/account/revoke to manually log out users but nothing seems to work. here is the implementation
#RestController
#RequestMapping("auth/account")
public class AccountResource {
#GetMapping("/revoke")
public void revoke(HttpServletRequest request) {
Assert.notNull(request, "HttpServletRequest required");
HttpSession session = request.getSession(false);
if (!Objects.isNull(session)) {
session.removeAttribute("SPRING_SECURITY_CONTEXT");
session.invalidate();
}
SecurityContextHolder.getContext().setAuthentication(null);
SecurityContextHolder.clearContext();
}
}
what could be the issue? any help counts
---------updates-------------
After upgrading spring-security-oauth2-authorization-server version 0.2.2 I updated this method
#Bean
#Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
return http.formLogin(Customizer.withDefaults()).build();
}
to this
#Bean
#Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
authorizationServerConfigurer.tokenRevocationEndpoint(tokenRevocationEndpoint -> tokenRevocationEndpoint
.revocationResponseHandler((request, response, authentication) -> {
Assert.notNull(request, "HttpServletRequest required");
HttpSession session = request.getSession(false);
if (!Objects.isNull(session)) {
session.removeAttribute("SPRING_SECURITY_CONTEXT");
session.invalidate();
}
SecurityContextHolder.getContext().setAuthentication(null);
SecurityContextHolder.clearContext();
response.setStatus(HttpStatus.OK.value());
})
);
RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
http
.requestMatcher(endpointsMatcher)
.authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.apply(authorizationServerConfigurer);
return http.formLogin(Customizer.withDefaults()).build();
}
There are two concepts in play that are somewhat confusingly related.
Secure logout
Token revocation
Regarding logging out of an application, this is necessary when a browser-based session is in use, which would usually be the case with the authorization_code flow. Strictly speaking, terminating the session is all that is required to achieve your goal.
Regarding token revocation, this is more of an OAuth-related security concern and is distinct in that sense from traditional logout functionality. Typically, the most immediate need for token revocation is as a risk mitigation strategy when a refresh_token (or to a lesser extent the associated access_token) is stolen. If a token is not stolen, one could conceivably discard the token from memory on the client-side, and simply let it expire on the server. However, this is unlikely to be a recommendation, as it allows risk to exist that can be closed down proactively by revoking the token on logout.
So the question is, how do we achieve both simultaneously, right? Unfortunately, it's not built-in to either Spring Security or Spring Authorization Server at the moment, though there are specifications that could be used as the basis of feature development.
In general, you will need to solve this on the backend, as there's not much a frontend can do. If you simply POST to a /logout endpoint from your UI, you can't manipulate the cookies stored for that host in the browser (when using CORS). I recommended using the backend-for-frontend pattern for your OAuth client. But even (and especially) if you don't, you will need to ensure you can terminate the session regardless of whether the cookie exists in the browser. This means storing the session in a database, associating it with the refresh token in some way, and making a secure call to the revocation endpoint using the refresh token to delete both from the database simultaneously.
You can achieve this by setting the AuthenticationSuccessHandler on the OAuth2TokenRevocationEndpointFilter, like so:
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
authorizationServerConfigurer.tokenRevocationEndpoint(tokenRevocationEndpoint -> tokenRevocationEndpoint
.revocationResponseHandler((request, response, authentication) -> {
/* delete session here... */
response.setStatus(HttpStatus.OK.value());
})
);
// ...
Hopefully that's enough to get you started. You may find some benefit in working through the specifics of how this might be achieved yourself (e.g. it's a good learning experience). If you're stuck, it could be a good opportunity to request a How-to guide, as we're fielding ideas for guides right now. See #499 for a list of existing how-to guides, and please feel free to submit your own!
For anyone facing the same issue, I was not able to make it work when using angular directly as a client, but following the advice of #SteveRiesenberg I recommended using the backend-for-frontend pattern for your OAuth client I was able to make it work, therefore I advise anyone to use this pattern as it will help you avoid some of the pitfalls you would otherwise have faced as I did, plus it integrates seamlessly. For a head start you can refer to the sample project below created by the spring security team SpringOne 2021 repository Also here is a link to the presentation Spring Security 5.5 From Taxi to Takeoff
I created success handler for revocation endpoint like Steve pointed out and invalidated sessions manually. Revoke is called from another application as a part of logout success handler.
RevocationResponseHandler:
#Bean
#Order(Ordered.HIGHEST_PRECEDENCE)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, OAuth2AuthorizationService authorizationService, FindByIndexNameSessionRepository<?> sessions) throws Exception {
var authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<HttpSecurity>();
authorizationServerConfigurer.tokenRevocationEndpoint(c -> c.revocationResponseHandler(sessionInvalidatingSuccessHandler(authorizationService, sessions)));
// ...
}
private AuthenticationSuccessHandler sessionInvalidatingSuccessHandler(OAuth2AuthorizationService authorizationService, FindByIndexNameSessionRepository<?> sessions) {
return (request, response, authentication) -> {
String token = request.getParameter(OAuth2ParameterNames.TOKEN);
if (token != null) {
OAuth2Authorization authorization = authorizationService.findByToken(token, null);
if (authorization != null) {
sessions.findByPrincipalName(authorization.getPrincipalName()).forEach((sessionId, session) -> sessions.deleteById(sessionId));
}
}
response.setStatus(HttpStatus.OK.value());
};
}
I faced the same issue. after spending five days of hard work, I found JSESSIONID to be removed from Server Side and Client Side.
I was trying the following things
revoked oauth2 token by calling authorization server's oauth2/revoke endpoint (both refresh and access tokens) but logout did not work.
I tried to call /logout endpoint of Authorization Server that also did not work out
I tried to remove JSESSIONID from client browser which also went in vein.
I tried to remove JSESSIONID from Server and Client side. that worked out. Now I am able to implement logout successfully
I added the following endpoint in Spring Authorization Server
#GetMapping("/do/logout")
#ResponseBody
public void doLogout(HttpSession session,HttpServletRequest request, HttpServletResponse response) {
System.out.println("entered logout point");
session.invalidate();
final Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
new SecurityContextLogoutHandler().logout(request, response, auth);
auth.setAuthenticated(false);
SecurityContextHolder.clearContext();
SecurityContextHolder.getContext().setAuthentication(null);
}
Cookie cookieWithSlash = new Cookie("JSESSIONID", null);
cookieWithSlash.setPath(request.getContextPath() + "/");
cookieWithSlash.setDomain("auth-server");
cookieWithSlash.setMaxAge(0);
response.addCookie(cookieWithSlash); // For Tomcat
}
and the following endpoint in client application
#PostMapping("/complete/logout/process")
public String testLogout(HttpSession session, HttpServletRequest request,
HttpServletResponse response,
#RegisteredOAuth2AuthorizedClient("messaging-client-oidc") OAuth2AuthorizedClient authorizedClient)
throws IOException {
final Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
new SecurityContextLogoutHandler().logout(request, response, auth);
auth.setAuthenticated(false);
SecurityContextHolder.clearContext();
for (Cookie cookie : request.getCookies()) {
String cookieName = cookie.getName();
LOG.info("cookie name={}", cookieName);
Cookie cookieToDelete = new Cookie(cookieName, null);
cookieToDelete.setPath(request.getContextPath() + "/");
cookieToDelete.setMaxAge(0);
response.addCookie(cookieToDelete);
}
SecurityContextHolder.getContext().setAuthentication(null);
}
return "logout";
}
and in html I called two endpoints /server side logout and client side logout
that cleared both the JSESSIONID cookie from the browser
<form method="post" id="clientLogout" th:action="#{/complete/logout/process}">
</form>
button class="btn btn-sm btn-outline-primary" type="button"
onclick="javascript:logout();">Logout</button>
<script>
function logout() {
// calling server side logout
var tmpwin = window.open("http://auth-server:9000/do/logout", "_blank");
// calling client side logout
document.getElementById("clientLogout").submit();
tmpwin.close();
}
</script>
logging out from server side and client side clears both the JSESSIONID cookies finally able to logout
if anything I am missing or doing wrong please educate me. Thanks
Additionally you may invalidate oauth2 access and refresh tokens if you want
#PostMapping("do/oauth2/token/revoke")
#ResponseBody
public void doOauth2TokenRevoke(#RegisteredOAuth2AuthorizedClient("messaging-client-oidc") OAuth2AuthorizedClient authorizedClient) {
String clientId = authorizedClient.getClientRegistration().getClientId();
String clientSecret = "secret";
String accesstoken = authorizedClient.getAccessToken().getTokenValue();
String refreshtoken = authorizedClient.getRefreshToken().getTokenValue();
LinkedMultiValueMap<String, String> map = new LinkedMultiValueMap<String, String>();
map.add("token", refreshtoken);
map.add("token_type_hint", "refresh_token");
WebClient client3 = WebClient.builder().baseUrl("http://auth-server:9000").build();
client3.post()
.uri("/oauth2/revoke")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.headers(h -> h.setBasicAuth(clientId, clientSecret))
.body(BodyInserters.fromMultipartData(map))
.retrieve()
.bodyToMono(Void.class)
.block();
LinkedMultiValueMap<String, String> map2 = new LinkedMultiValueMap<String, String>();
map2.add("token", accesstoken);
map2.add("token_type_hint", "access_token");
WebClient client2 = WebClient.builder().baseUrl("http://auth-server:9000").build();
client2.post()
.uri("/oauth2/revoke")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.headers(h -> h.setBasicAuth(clientId, clientSecret))
.body(BodyInserters.fromMultipartData(map2))
.retrieve()
.bodyToMono(Void.class)
.block();
}
On defaultSecurityFilterChain set SessionCreationPolicy to NEVER and add a custom logOut Filter that invalidates the session and redirects to your redirect Uri.
And from your UI make a post to /logout?redirect_uri=
#Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http, UserService userService) throws Exception {
http.csrf().disable();
http
.formLogin().loginPage("/login")
.and()
.logout(logout->{
logout.logoutUrl("/logout");
})
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER)
.and()
.authorizeRequests()
.antMatchers("/login*").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(new CustomLogoutFilter(), SecurityContextPersistenceFilter.class);
return http.build();
}
public class CustomLogoutFilter extends OncePerRequestFilter {
#Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if(request.getServletPath().equals("/logout")) {
HttpSession session = request.getSession(false);
if(session != null) {
log.info("Invalidating session");
session.invalidate();
}
SecurityContext context = SecurityContextHolder.getContext();
if(context != null) {
log.info("Clearing security context");
SecurityContextHolder.clearContext();
context.setAuthentication(null);
}
String redirectUri = request.getParameter("redirect_uri");
if(Utils.isEmpty(redirectUri)) {
redirectUri = request.getHeader("referer");
}
if(Utils.notEmpty(redirectUri)) {
response.sendRedirect(redirectUri);
return;
}
}
filterChain.doFilter(request, response);
}
}
I managed to create a filter named TokenValidationFilter at resource server that calls authorization server's introspect endpoint resulting in the following:
/**
* Performs token introspection when
* an authenticated endpoint gets hit.
*/
#Component
public class TokenValidationFilter extends OncePerRequestFilter {
#Value( "${spring.security.oauth2.client.provider.authz-server.introspect}" )
private String introspectEndpoint;
#Value( "${spring.security.oauth2.client.registration.authz-server.client-id}" )
private String clientId;
#Value(
"${spring.security.oauth2.client.registration.authz-server.client-secret}" )
private String clientSecret;
#Override
public void doFilterInternal( HttpServletRequest request,
HttpServletResponse response, FilterChain chain )
throws ServletException {
try {
var token = request.getHeader( "authorization" );
// For endpoints that do not require Authorization header claim
if (token == null) {
chain.doFilter(request, response);
return;
}
var result = performTokenIntrospection( token );
// Parses authz-server http response into json
ObjectMapper mapper = new ObjectMapper();
JsonNode json = mapper.readTree( result.body() );
// Checks token validation
if ( json.get( "active" ).asBoolean( false ) ) {
chain.doFilter(request, response);
} else {
prepareRejectionResponse( response );
}
}
catch ( Exception ex ) {
throw new ServletException( ex );
}
}
private HttpResponse<String> performTokenIntrospection( String token )
throws IOException, InterruptedException,
NullPointerException, URISyntaxException {
var uri = new URI( introspectEndpoint );
// Prepares request to authz server introspection endpoint
var body = String.format( "token=%s&client_id=%s&client_secret=%s",
token.replaceAll( "[Bb][Ee][Aa][Rr][Ee][Rr] ", "" ),
clientId, clientSecret);
var req = HttpRequest
.newBuilder()
.uri(uri)
.headers(
"content-type", MediaType.APPLICATION_FORM_URLENCODED_VALUE,
"authorization", token
)
.POST( HttpRequest.BodyPublishers.ofString( body ) )
.build();
// Performs token introspection and returns its result
return HttpClient.newHttpClient()
.send( req, HttpResponse.BodyHandlers.ofString() );
}
private void prepareRejectionResponse( HttpServletResponse response )
throws IOException {
response.setStatus( HttpStatus.UNAUTHORIZED.value() );
response.setContentType( MediaType.APPLICATION_JSON_VALUE );
var writer = response.getWriter();
writer.print( "{\"message\": \"invalid token\"}" );
writer.close();
}
}
As you can see the filter attributes uses resource server's ( client-id and client-secret ) credentials, and i added token introspection's endpoint on my application.yml file.
Finally, registering the filter in the configuration class my ResourceServerConfig looked like:
#EnableWebSecurity
public class ResourceServerConfig {
#Bean
SecurityFilterChain securityFilterChain( HttpSecurity http )
throws Exception {
//... omitted code above ...
http.addFilterBefore( tokenValidationFilter,
WebAsyncManagerIntegrationFilter.class );
return http.build();
}
// ... omitted code bellow ...
}
I just tested on development environment but yet to find its drawbacks, there might be better solutions but it works without spamming another application.
As far as i know (which is little btw), the solution presented backend-for-frontend looks better in security perspective.
Obs: if you apply this solution remeber always to revoke by refresh_token as oauth2 framework specifies.
.antMatchers("/auth/account/**").authenticated() ?

Calling micro service from spring cloud gateway

In spring cloud gateway, added a filter that check for the authentication and authorization for further processing of request. I am calling authentication service using feign client, but I am getting the below error while invoking my service through spring cloud gateway.
java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-epoll-3\n\tat reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:83)\n\tSuppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: \nError has been observed at the following site(s):\n\t|_ checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter ....."
I would like to know is it wrong architecture I am using. How to proceed? I am stuck at this error.
#Autowired
private AuthenticationService authService;
// route validator
#Autowired
private RouterValidator routerValidator;
#Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
if (routerValidator.isSecured.test(request)) {
log.info("Accessing the restricted path");
if (this.isAuthMissing(request))
return this.onError(exchange, "Authorization header is missing in request", HttpStatus.UNAUTHORIZED);
final String token = this.getAuthHeader(request);
log.info("before authservice call");
AuthenticationResponse user = authService.isTokenValid(token);
log.info("after authservice call");
if (!user.isValid())
return this.onError(exchange, "Authorization header is invalid", HttpStatus.UNAUTHORIZED);
log.info("before calling populatedRequest");
this.populateRequestWithHeaders(exchange, user);
}
return chain.filter(exchange);
}
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
return response.setComplete();
}
private String getAuthHeader(ServerHttpRequest request) {
return request.getHeaders().getOrEmpty("Authorization").get(0);
}
private boolean isAuthMissing(ServerHttpRequest request) {
log.info("inside auth missing");
return !request.getHeaders().containsKey("Authorization");
}
private void populateRequestWithHeaders(ServerWebExchange exchange, AuthenticationResponse authRes) {
log.info("About to mutate the request->{}",exchange);
exchange.getRequest().mutate()
.header("id",Integer.toString(authRes.getUserId()))
.build();
}
Feign interface
#Autowired
private AuthenticationFeign auth;
public AuthenticationResponse isTokenValid(String token) {
return auth.getValidity(token);
}
I couldn't clearly read it. But problem is that: you can not make blocking call in filter pipeline. Current reactive impl. is like that. if you want, u can use .then() method of WebClient. U should use webclient. because it's reactive.
this link may help you:
https://github.com/spring-cloud/spring-cloud-gateway/issues/980
There was a long time, but i want to give answer. I hope, this help u, please response back, it works or not.

How to send java.security.Principal with a webclient request

There is a rest api secured using keycloak(OAUTH) as below:
#PreAuthorize("hasRole('user')")
#GetMapping(value = "/employees", produces = { MediaType.APPLICATION_JSON_VALUE })
public ResponseEntity<List<Employee>> findAll(java.security.Principal principal,
#RequestParam(name = "filterationCriteria", required = false) String fields) {
if(Objects.nonNull(principal)){
return employeeManagementService.findAll(fields);
}
return null;
}
i want to consume this api using webclient as below:
public <T> T get(URI url, Class<T> responseType, Principal principal) {
return WebClient.builder().build().get().uri(url)
.header("Authorization", "Basic " + principal)
.retrieve().bodyToMono(responseType).block();
}
above method is throwing below exception from findAll() method, which generally happens if the principal is not found:
org.springframework.web.reactive.function.client.WebClientResponseException$Unauthorized:
401 Unauthorized from GET
Am i doing something wrong, is that the correct way to send principal with the request?
Any suggestion is appreciated.
Thanks in advance.
UPDATE
I am sending request to the secured rest-api using webclient from a different service and i need to pass the principal manually(may be including it into the header).
if i simply do below:
WebClient.builder().build().get().uri(url).retrieve().bodyToMono(responseType).block()
then the principal is coming null in the findAll method
Security config
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.authorizeRequests()
.anyRequest().permitAll().and()
.csrf().disable();
}
NOTE: i have put Authorize constraint at method level.
I have found a solution. the principal has to be sent like this:
public <T> T get(URI url, Class<T> responseType, Principal principal) {
RequestHeadersSpec uri = webClient.get().uri(url);
if (Objects.nonNull(principal)) {
uri = uri.header("Authorization", "Bearer "
+ ((KeycloakAuthenticationToken) principal).getAccount().getKeycloakSecurityContext().getTokenString());
}
ResponseSpec response = uri.retrieve();
Mono<T> bodyToMono = response.bodyToMono(responseType);
return bodyToMono.block();
}

Spring WebClient perform https call

Does anyone know how to configure WebClient in order to make an HTTPS endpoint?
My config looks like that:
#Bean
#NonNull
public WebClient webClient() throws SSLException {
final SslContext context = SslContextBuilder.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build();
final HttpClient httpClient = HttpClient.create().secure(t -> t.sslContext(context));
return WebClient
.builder()
.exchangeStrategies(ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024))
.build())
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
here is the method witch hits HTTPS endpoint
#Nullable
public AccessToken getAccessToken() {
return webClient
.post()
.uri(uriBuilder -> uriBuilder.path(authUrl)
.queryParam("username", username)
.queryParam("password", password)
.queryParam("client_id", clientId)
.queryParam("client_secret", clientSecret)
.queryParam("grant_type", "password")
.build())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.exchange()
.flatMap(response -> {
//Error handling
if (response.statusCode().isError()) {
logger.error("error occured while authentication: {}", response.statusCode());
return response.createException().flatMap(Mono::error);
}
return response.bodyToMono(AccessToken.class);
})
.subscribeOn(Schedulers.elastic())
.block();
}
and that's my reponse, unfortunately I'm not allowed to show all the details cause there are secured data.
So I've checked everything like URL, parameters, all looks fine. Also if do the same with restTemaple it works.
Caused by: java.net.UnknownHostException: https:
at java.base/java.net.InetAddress$CachedAddresses.get(InetAddress.java:798)
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
|_ checkpoint ⇢ Request to POST https:/<here goes secured endpoint with query parameters>

How to make reactive webclient follow 3XX-redirects?

I have created a basic REST controller which makes requests using the reactive Webclient in Spring-boot 2 using netty.
#RestController
#RequestMapping("/test")
#Log4j2
public class TestController {
private WebClient client;
#PostConstruct
public void setup() {
client = WebClient.builder()
.baseUrl("http://www.google.com/")
.exchangeStrategies(ExchangeStrategies.withDefaults())
.build();
}
#GetMapping
public Mono<String> hello() throws URISyntaxException {
return client.get().retrieve().bodyToMono(String.class);
}
}
When I get a 3XX response code back I want the webclient to follow the redirect using the Location in the response and call that URI recursively until I get a non 3XX response.
The actual result I get is the 3XX response.
You need to configure the client per the docs
WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create().followRedirect(true)
))
You could make the URL parameter of your function, and recursively call it while you're getting 3XX responses. Something like this (in real implementation you would probably want to limit the number of redirects):
public Mono<String> hello(String uri) throws URISyntaxException {
return client.get()
.uri(uri)
.exchange()
.flatMap(response -> {
if (response.statusCode().is3xxRedirection()) {
String redirectUrl = response.headers().header("Location").get(0);
return response.bodyToMono(Void.class).then(hello(redirectUrl));
}
return response.bodyToMono(String.class);
}

Resources