I am trying to configure resource server with my spring boot kotlin project, basically i have a bearer token which is opaque token i pass it to my rest controller which comes from mobile app and on my server side i need to authenticate along with custom authorization server(opaquetoken) with url https://**.com/oauth2/token
My current configuration is as below
application.yml
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://****/oauth2/introspect
client-id: XXXX
client-secret: XXXX
and:
sample code as below
#SpringBootApplication
class DemoApplication
#RestController
class HelloController {
#GetMapping("/hello")
fun hello(#AuthenticationPrincipal principal: OAuth2AuthenticatedPrincipal) = "hello ${principal.getAttribute<Any>("sub")}"
#GetMapping("/hello2")
fun hello2() = "hello2"
}
#Configuration
class WebSecurityConfigurerAdapter2 : WebSecurityConfigurerAdapter() {
#Value("\${spring.security.oauth2.resourceserver.opaque.introspection-uri}")
var introspectionUri: String? = null
#Value("\${spring.security.oauth2.resourceserver.opaque.client-id}")
var clientId: String? = null
#Value("\${spring.security.oauth2.resourceserver.opaque.client-secret}")
var clientSecret: String? = null
#Throws(Exception::class)
override fun configure(http: HttpSecurity) {
http
.authorizeRequests { authz ->
authz
.antMatchers("/hello")
.authenticated()
}
.oauth2ResourceServer { oauth2: OAuth2ResourceServerConfigurer<HttpSecurity?> ->
oauth2
.opaqueToken { token ->
val resourceDetails = ClientCredentialsResourceDetails()
resourceDetails.accessTokenUri = "https://****/oauth2/token"
resourceDetails.clientId = clientId
resourceDetails.clientSecret = clientSecret
resourceDetails.scope = listOf("***")
val restTemplate = OAuth2RestTemplate(resourceDetails)
token.introspector(NimbusOpaqueTokenIntrospector(introspectionUri, restTemplate))
}
}
}
}
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args)
}
Everything works fine and i am able to get principal object after i make a call with bearer token to GET http://localhost:8080/hello with Authorization Bearer but i see ClientCredentialsResourceDetails and OAuth2RestTemplate are deprecated, is there any latest document or sample code for this custom auth server and introspect for rest.
Appreciate help and suggestions!
Issue is solved -
and:
sample code as below
#Component
class CustomRequestEntityConverter(
#Value("${spring.security.oauth2.resourceserver.opaquetoken.access-token-uri}") val accessTokenUri: URI,
val properties: OAuth2ResourceServerProperties
) :
Converter<String, RequestEntity<*>> {
private val restTemplate = RestTemplate()
override fun convert(token: String): RequestEntity<*>? {
val headers: HttpHeaders = requestHeaders(authToken())
val body: MultiValueMap<String, String> = requestBody(token)
return RequestEntity(
body,
headers,
HttpMethod.POST,
URI.create(properties.opaquetoken.introspectionUri)
)
}
/**
* Obtains auth-token from the Authentication server using client-credentials flow.
*/
private fun authToken(): String {
val headers = HttpHeaders()
headers.setBasicAuth(properties.opaquetoken.clientId, properties.opaquetoken.clientSecret)
headers.contentType = MediaType.APPLICATION_FORM_URLENCODED
val bodyParamMap = LinkedMultiValueMap<String, String>()
bodyParamMap.add("grant_type", "client_credentials");
val entity = HttpEntity<MultiValueMap<String, String>>(bodyParamMap, headers)
return restTemplate.postForObject(accessTokenUri, entity, JsonNode::class.java)
?.get("access_token")?.asText()
?: throw RuntimeException("Failed to retrieve access token from authentication server")
}
private fun requestHeaders(bearerToken: String): HttpHeaders {
val headers = HttpHeaders()
headers.setBearerAuth(bearerToken)
headers.accept = listOf(MediaType.APPLICATION_JSON)
return headers
}
private fun requestBody(token: String): MultiValueMap<String, String> {
val body: MultiValueMap<String, String> = LinkedMultiValueMap()
body.add("token", token)
return body
}
}
#Configuration
class WebSecurityConfigurerAdapter2 : WebSecurityConfigurerAdapter() {
#Bean
fun opaqueTokenIntrospector(
properties: OAuth2ResourceServerProperties,
conv: CustomRequestEntityConverter
): NimbusOpaqueTokenIntrospector {
val introspector = NimbusOpaqueTokenIntrospector(
properties.opaquetoken.introspectionUri,
properties.opaquetoken.clientId,
properties.opaquetoken.clientSecret
)
introspector.setRequestEntityConverter(conv)
return introspector
}
override fun configure(http: HttpSecurity) {
http
.authorizeRequests { authz ->
authz
.antMatchers("/hello")
.authenticated()
}
.oauth2ResourceServer {
it
.opaqueToken()
}
}
}
Related
I am using Swagger with keycloak. I am getting the below error when I click on Execute button.
when I click on Execute on swagger I see the loading image, and there is no any request on the network tap because I have an error on the console.
I will add my config code and YML file to allow you to see what I do.
anyone can help me, please?
Here is the output in the console:
Here is the error code in the console tap (Its generated code):
here is the swagger UI page:
here is the swagger config:
#Slf4j
#Configuration
#TbCoreComponent
public class SwaggerConfiguration {
#Value("${swagger.api_path_regex}")
private String apiPathRegex;
#Value("${swagger.security_path_regex}")
private String securityPathRegex;
#Value("${swagger.non_security_path_regex}")
private String nonSecurityPathRegex;
#Value("${swagger.title}")
private String title;
#Value("${swagger.description}")
private String description;
#Value("${swagger.contact.name}")
private String contactName;
#Value("${swagger.contact.url}")
private String contactUrl;
#Value("${swagger.contact.email}")
private String contactEmail;
#Value("${swagger.version}")
private String version;
#Value("${app.version:unknown}")
private String appVersion;
// Used to get token from Keyclaok
#Value("${swagger.auth.token_url}")
private String keycloakAuthTokenUrl;
#Value("${security.keycloak.realm}")
private String keycloakRealm;
#Value("${security.keycloak.clientId}")
private String keyclaokAuthCliendId;
#Bean
public Docket yousefApi() {
TypeResolver typeResolver = new TypeResolver();
return new Docket(DocumentationType.SWAGGER_2)
.groupName("Hammad")
.apiInfo(apiInfo())
.additionalModels(
typeResolver.resolve(ThingsboardErrorResponse.class),
typeResolver.resolve(ThingsboardCredentialsExpiredResponse.class)
)
.select()
.paths(apiPaths())
.paths(any())
.build()
.globalResponses(HttpMethod.GET, defaultErrorResponses(false))
.globalResponses(HttpMethod.POST, defaultErrorResponses(true))
.globalResponses(HttpMethod.DELETE, defaultErrorResponses(false))
.securitySchemes(newArrayList(apiKey()))
.securityContexts(newArrayList(securityContext()))
.enableUrlTemplating(true);
}
#Bean
#Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER)
ApiListingBuilderPlugin loginEndpointListingBuilder() {
return new ApiListingBuilderPlugin() {
#Override
public void apply(ApiListingContext apiListingContext) {
if (apiListingContext.getResourceGroup().getGroupName().equals("Hammad")) {
ApiListing apiListing = apiListingContext.apiListingBuilder().build();
if (apiListing.getResourcePath().equals(keycloakAuthTokenUrl)) {
apiListingContext.apiListingBuilder().tags(Set.of(new Tag("login-endpoint", "Login Endpoint")));
apiListingContext.apiListingBuilder().description("Login Endpoint");
}
}
}
#Override
public boolean supports(#NotNull DocumentationType delimiter) {
return DocumentationType.SWAGGER_2.equals(delimiter) || DocumentationType.OAS_30.equals(delimiter);
}
};
}
#Bean
UiConfiguration uiConfig() {
return UiConfigurationBuilder.builder()
.deepLinking(true)
.displayOperationId(false)
.defaultModelsExpandDepth(1)
.defaultModelExpandDepth(1)
.defaultModelRendering(ModelRendering.EXAMPLE)
.displayRequestDuration(false)
.docExpansion(DocExpansion.NONE)
.filter(false)
.maxDisplayedTags(null)
.operationsSorter(OperationsSorter.ALPHA)
.showExtensions(false)
.showCommonExtensions(false)
.supportedSubmitMethods(UiConfiguration.Constants.DEFAULT_SUBMIT_METHODS)
.validatorUrl(null)
.persistAuthorization(true)
.syntaxHighlightActivate(true)
.syntaxHighlightTheme("agate")
.build();
}
private ApiKey apiKey() {
return new ApiKey("Bearer", "X-Authorization", "header");
}
private OAuth securityScheme() {
List<GrantType> grantTypes = newArrayList(new ResourceOwnerPasswordCredentialsGrant(keycloakAuthTokenUrl));
return new OAuth("KeycloakAuth", new ArrayList<>(), grantTypes);
}
private SecurityContext securityContext() {
return SecurityContext.builder()
.securityReferences(defaultAuth())
.operationSelector(securityPathOperationSelector())
.build();
}
private Predicate<String> apiPaths() {
return regex(apiPathRegex);
}
private Predicate<OperationContext> securityPathOperationSelector() {
return new SecurityPathOperationSelector(securityPathRegex, nonSecurityPathRegex);
}
private AuthorizationScope[] scopes() {
AuthorizationScope[] authorizationScopes = new AuthorizationScope[3];
authorizationScopes[0] = new AuthorizationScope(Authority.SYS_ADMIN.name(), "System administrator");
authorizationScopes[1] = new AuthorizationScope(Authority.TENANT_ADMIN.name(), "Tenant administrator");
authorizationScopes[2] = new AuthorizationScope(Authority.CUSTOMER_USER.name(), "Customer");
return authorizationScopes;
}
List<SecurityReference> defaultAuth() {
return newArrayList(new SecurityReference("KeycloakAuth", scopes()), new SecurityReference("Bearer", scopes()));
}
private ApiInfo apiInfo() {
String apiVersion = version;
if (StringUtils.isEmpty(apiVersion)) {
apiVersion = appVersion;
}
return new ApiInfoBuilder()
.title(title)
.description(description)
.contact(new Contact(contactName, contactUrl, contactEmail))
.version(apiVersion)
.build();
}
/** Helper methods **/
private List<Response> defaultErrorResponses(boolean isPost) {
return List.of(
errorResponse("400", "Bad Request",
ThingsboardErrorResponse.of(isPost ? "Invalid request body" : "Invalid UUID string: 123", ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST)),
errorResponse("401", "Unauthorized",
ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)),
errorResponse("403", "Forbidden",
ThingsboardErrorResponse.of("You don't have permission to perform this operation!",
ThingsboardErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN)),
errorResponse("404", "Not Found",
ThingsboardErrorResponse.of("Requested item wasn't found!", ThingsboardErrorCode.ITEM_NOT_FOUND, HttpStatus.NOT_FOUND)),
errorResponse("429", "Too Many Requests",
ThingsboardErrorResponse.of("Too many requests for current tenant!",
ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS))
);
}
private Response errorResponse(String code, String description, ThingsboardErrorResponse example) {
return errorResponse(code, description, List.of(errorExample("error-code-" + code, description, example)));
}
private Response errorResponse(String code, String description, List<Example> examples) {
return errorResponse(code, description, examples, ThingsboardErrorResponse.class);
}
private Response errorResponse(String code, String description, List<Example> examples,
Class<? extends ThingsboardErrorResponse> errorResponseClass) {
return new ResponseBuilder()
.code(code)
.description(description)
.examples(examples)
.representation(MediaType.APPLICATION_JSON)
.apply(classRepresentation(errorResponseClass, true))
.build();
}
private Example errorExample(String id, String summary, ThingsboardErrorResponse example) {
return new ExampleBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.summary(summary)
.id(id)
.value(example).build();
}
private Consumer<RepresentationBuilder> classRepresentation(Class<?> clazz, boolean isResponse) {
return r -> r.model(
m ->
m.referenceModel(ref ->
ref.key(k ->
k.qualifiedModelName(q ->
q.namespace(clazz.getPackageName())
.name(clazz.getSimpleName())).isResponse(isResponse)))
);
}
private static class SecurityPathOperationSelector implements Predicate<OperationContext> {
private final Predicate<String> securityPathSelector;
SecurityPathOperationSelector(String securityPathRegex, String nonSecurityPathRegex) {
this.securityPathSelector = (not(regex(nonSecurityPathRegex)));
}
#Override
public boolean test(OperationContext operationContext) {
return this.securityPathSelector.test(operationContext.requestMappingPattern());
}
}
}
here is the YML file:
swagger:
auth:
token_url: ${security.keycloak.serverUrl}/realms/${security.keycloak.realm}/protocol/openid-connect/token/
auth_url: ${security.keycloak.serverUrl}/realms/${security.keycloak.realm}/protocol/openid-connect/auth/
api_path_regex: "${SWAGGER_API_PATH_REGEX:/api/(customer|device|user|tenant).*}"
security_path_regex: "${SWAGGER_SECURITY_PATH_REGEX:/api/(customer|device|user|tenant).*}"
non_security_path_regex: "${SWAGGER_NON_SECURITY_PATH_REGEX:/api/(?:noauth|v1)/.*}"
title: "${SWAGGER_TITLE:yousefCo REST API}"
description: "${SWAGGER_DESCRIPTION: yousefCo open-source IoT platform REST API documentation.}"
contact:
name: "${SWAGGER_CONTACT_NAME:yousefCo Team}"
url: "${SWAGGER_CONTACT_URL:http://iot.test.net}"
email: "${SWAGGER_CONTACT_EMAIL:info#gmail.com}"
license:
title: "${SWAGGER_LICENSE_TITLE:Apache License Version 2.0}"
url: "${SWAGGER_LICENSE_URL:https://github.com/yousef/yousef/blob/master/LICENSE}"
version: "${SWAGGER_VERSION:}"
I think you have mixed the configuration between swagger version 2 and 3, your overall setting is configured to be used in Swagger 3.0, so
First, You have to change the DocumentationType to OAS_30.
Second, When using the ApiKey security scheme, note that the field name will be used as the header when calling an API, so you have to change it to:
private ApiKey apiKey() {
return new ApiKey("X-Authorization", "AnyNameYouWant", "header");
}
Note that you have to add the Bearer word before the token, also you have to change the name SecurityReference to X-Authorization.
Third, If you need to config the OAuth2 security scheme, instead of using the OAuth class, use the OAuth2Scheme builder class with password flow, like this:
return OAuth2Scheme.OAUTH2_PASSWORD_FLOW_BUILDER
.name("KeycloakAuth")
.scopes(newArrayList(scopes()))
.tokenUrl(keycloakAuthTokenUrl)
.refreshUrl(keycloakAuthTokenUrl)
.build();
Don't forget to add the same scopes in SecurityReference into the OAuth2Scheme scopes.
I'm trying to configure SpringDoc/Swagger-UI in order to show only the Implicit Flow
when clicking on the Authorize button.
However, it shows all the possible authorization methods supported by the IDAM,
as show at /.well-known/openid-configuration:
"grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:ietf:params:oauth:grant-type:device_code","urn:openid:params:grant-type:ciba"]
authorization_code
implicit
refresh_token
password
client_credentials
urn:ietf:params:oauth:grant-type:device_code
urn:openid:params:grant-type:ciba
This is my current configuration:
#Configuration
#RequiredArgsConstructor
public class OpenAPIConfiguration {
private final OAuth2Configuration oAuth2Configuration;
#Bean
public SecurityScheme securityScheme() {
String tokenIssuer = this.oAuth2Configuration.getIssuers().get(0);
String openIdConnectUrl = tokenIssuer + "/.well-known/openid-configuration";
OAuthFlow implicitOAuthFlow = new OAuthFlow();
return new SecurityScheme()
.name("OIDC-Auth")
.type(SecurityScheme.Type.OPENIDCONNECT)
.scheme("bearer")
.bearerFormat("jwt")
.in(SecurityScheme.In.HEADER)
.openIdConnectUrl(openIdConnectUrl)
.flows(new OAuthFlows().implicit(implicitOAuthFlow));
}
#Bean
public SecurityRequirement securityRequirement() {
return new SecurityRequirement().addList("OIDC-Auth");
}
#Bean
public OpenAPI openAPI(SecurityScheme securityScheme, SecurityRequirement securityRequirement) {
return new OpenAPI()
.info(new Info()
.title("MY API")
.version("1"))
.components(new Components()
.addSecuritySchemes(securityScheme.getName(), securityScheme))
.addSecurityItem(securityRequirement);
}
}
How can I limit the flows to be displayed on the UI?
The example below works for me:
...
private static final String PROTOCOL_OPENID_CONNECT = "%s/realms/%s/protocol/openid-connect";
#Bean
OpenAPI customOpenApi() {
return new OpenAPI()
.addServersItem(new Server().url(API_SERVER_URL))
.components(createOauth2SecurityScheme())
.security(createOauth2SecurityRequirement())
.info(createInfo());
}
private Components createOauth2SecurityScheme() {
return new Components().addSecuritySchemes("oAuth2", createOauth2Scheme());
}
private List<SecurityRequirement> createOauth2SecurityRequirement() {
return List.of(new SecurityRequirement().addList("oAuth2"));
}
private SecurityScheme createOauth2Scheme() {
String authUrl = String.format(PROTOCOL_OPENID_CONNECT, AUTH_SERVER_URL, REALM);
String tokenUrl = String.format(PROTOCOL_OPENID_CONNECT, AUTH_SERVER_URL, REALM);
return new SecurityScheme()
.type(SecurityScheme.Type.OAUTH2)
.description("OAuth2 Flow")
.flows(new OAuthFlows()
.authorizationCode(
new OAuthFlow()
.authorizationUrl(authUrl + "/auth")
.tokenUrl(tokenUrl + "/token")
.scopes(new Scopes())
));
}
...
The documentation at spring security is missing important detail. Our idp does not provide an introspection link, and our resource server is not a client in its own right. It receives JWT access tokens from the actual client, and "needs to know" details about the user associated with the access token.
In our case standard jwt processing gives us a useful start, but we need to fill out the authentication with the claims from userinfo.
How do we 1. get a baseline valid oauth2 authentication, 2. fill it out with the results of the userinfo call.
public class UserInfoOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
private final OpaqueTokenIntrospector delegate =
new NimbusOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret");
private final WebClient rest = WebClient.create();
#Override
public OAuth2AuthenticatedPrincipal introspect(String token) {
OAuth2AuthenticatedPrincipal authorized = this.delegate.introspect(token);
return makeUserInfoRequest(authorized);
}
}
Current implementation using a converter:
#Configuration
public class JWTSecurityConfig extends WebSecurityConfigurerAdapter {
#Autowired JwtConverterWithUserInfo jwtConverter;
#Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.csrf().disable()
.authorizeRequests(authz -> authz
.antMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtConverter);
}
}
#Configuration
public class WebClientConfig {
/**
* Provides a Web-Client Bean containing the bearer token of the authenticated user.
*/
#Bean
WebClient webClient(){
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(5))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(new ServletBearerExchangeFilterFunction())
.build();
}
}
#Component
#Log4j2
public class JwtConverterWithUserInfo implements Converter<Jwt, AbstractAuthenticationToken> {
#Autowired WebClient webClient;
#Value("${userinfo-endpoint}")
String userinfoEndpoint;
#SuppressWarnings("unchecked")
#Override
public AbstractAuthenticationToken convert(Jwt jwt) {
String token = jwt.getTokenValue();
log.debug("Calling userinfo endpoint for token: {}", token);
String identityType = jwt.getClaimAsString("identity_type");
Map<String,Object> userInfo = new HashMap<>();
if ("user".equals(identityType)) {
// invoke the userinfo endpoint
userInfo =
webClient.get()
.uri(userinfoEndpoint)
.headers(h -> h.setBearerAuth(token))
.retrieve()
.onStatus(s -> s.value() >= HttpStatus.SC_BAD_REQUEST, response -> response.bodyToMono(String.class).flatMap(body -> {
return Mono.error(new HttpException(String.format("%s, %s", response.statusCode(), body)));
}))
.bodyToMono(Map.class)
.block();
log.debug("User info Map is: {}",userInfo);
// construct an Authentication including the userinfo
OidcIdToken oidcIdToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims());
OidcUserInfo oidcUserInfo = new OidcUserInfo(userInfo);
List<OidcUserAuthority> authorities = new ArrayList<>();
if (oidcIdToken.hasClaim("scope")) {
String scope = String.format("SCOPE_%s", oidcIdToken.getClaimAsString("scope"));
authorities.add(new OidcUserAuthority(scope, oidcIdToken, oidcUserInfo));
}
OidcUser oidcUser = new DefaultOidcUser(authorities, oidcIdToken, oidcUserInfo, IdTokenClaimNames.SUB);
//TODO replace this OAuth2 Client authentication with a more appropriate Resource Server equivalent
return new OAuth2AuthenticationTokenWithCredentials(oidcUser, authorities, oidcUser.getName());
} else {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
if (jwt.hasClaim("scope")) {
authorities.add(new SimpleGrantedAuthority(String.format("SCOPE_%s", jwt.getClaimAsString("scope"))));
}
return new JwtAuthenticationToken(jwt, authorities);
}
}
}
public class OAuth2AuthenticationTokenWithCredentials extends OAuth2AuthenticationToken {
public OAuth2AuthenticationTokenWithCredentials(OAuth2User principal,
Collection<? extends GrantedAuthority> authorities,
String authorizedClientRegistrationId) {
super(principal, authorities, authorizedClientRegistrationId);
}
#Override
public Object getCredentials() {
return ((OidcUser) this.getPrincipal()).getIdToken();
}
}
Instead of a custom OpaqueTokenIntrospector, try a custom JwtAuthenticationConverter:
#Component
public class UserInfoJwtAuthenticationConverter implements Converter<Jwt, BearerTokenAuthentication> {
private final ClientRegistrationRepository clients;
private final JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
#Override
public BearerTokenAuthentication convert(Jwt jwt) {
// Spring Security has already verified the JWT at this point
OAuth2AuthenticatedPrincipal principal = invokeUserInfo(jwt);
Instant issuedAt = jwt.getIssuedAt();
Instant expiresAt = jwt.getExpiresAt();
OAuth2AccessToken token = new OAuth2AccessToken(
BEARER, jwt.getTokenValue(), issuedAt, expiresAt);
Collection<GrantedAuthority> authorities = this.authoritiesConverter.convert(jwt);
return new BearerTokenAuthentication(principal, token, authorities);
}
private OAuth2AuthenticatedPrincipal invokeUserInfo(Jwt jwt) {
ClientRegistration registration =
this.clients.findByRegistrationId("registration-id");
OAuth2UserRequest oauth2UserRequest = new OAuth2UserRequest(
registration, jwt.getTokenValue());
return this.oauth2UserService.loadUser(oauth2UserRequest);
}
}
And then wire into the DSL like so:
#Bean
SecurityFilterChain web(
HttpSecurity http, UserInfoJwtAuthenticationConverter authenticationConverter) {
http
.oauth2ResourceServer((oauth2) -> oauth2
.jwt((jwt) -> jwt.jwtAuthenticationConverter())
);
return http.build();
}
our resource server is not a client in its own right
oauth2-client is where Spring Security's support for invoking /userinfo lives and ClientRegistration is where the application's credentials are stored for addressing /userinfo. If you don't have those, then you are on your own to invoke the /userinfo endpoint yourself. Nimbus provides good support, or you may be able to simply use RestTemplate.
How can we use #AuthenticationPrincipal with a RSocket Method #AuthenticationPrincipal Mono token
public Mono<String> uppercase(String s, #AuthenticationPrincipal Mono<JwtAuthenticationToken> token) {
//Token is always null
return Mono.just(s.toUpperCase());
}
I created a RSocketSecurityConfiguration class:
#Configuration
#EnableRSocketSecurity
#EnableReactiveMethodSecurity
#Slf4j
public class RSocketSecurityConfiguration {
#Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
#Bean
PayloadSocketAcceptorInterceptor rsocketInterceptor(RSocketSecurity rsocket) {
rsocket
.authorizePayload(authorize ->
authorize
.anyRequest().authenticated()
.anyExchange().permitAll()
)
.jwt(jwtSpec -> {
jwtSpec.authenticationManager(jwtReactiveAuthenticationManager(reactiveJwtDecoder()));
});
return rsocket.build();
}
#Bean
ReactiveJwtDecoder reactiveJwtDecoder() {
NimbusReactiveJwtDecoder decoder = (NimbusReactiveJwtDecoder)
ReactiveJwtDecoders.fromOidcIssuerLocation(issuerUri);
return decoder;
}
#Bean
public JwtReactiveAuthenticationManager jwtReactiveAuthenticationManager(ReactiveJwtDecoder reactiveJwtDecoder) {
JwtReactiveAuthenticationManager jwtReactiveAuthenticationManager = new JwtReactiveAuthenticationManager(reactiveJwtDecoder);
JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
authenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
jwtReactiveAuthenticationManager.setJwtAuthenticationConverter( new ReactiveJwtAuthenticationConverterAdapter(authenticationConverter));
return jwtReactiveAuthenticationManager;
}
#Bean
RSocketMessageHandler messageHandler(RSocketStrategies strategies) {
RSocketMessageHandler mh = new RSocketMessageHandler();
mh.getArgumentResolverConfigurer().addCustomResolver(new AuthenticationPrincipalArgumentResolver());
mh.setRSocketStrategies(strategies);
return mh;
}
Full UpperCaseController:
#Slf4j
#Controller
public class UpperCaseController {
#MessageMapping("uppercase")
public Mono<String> uppercase(String s, #AuthenticationPrincipal Mono<JwtAuthenticationToken> token) {
JwtAuthenticationToken currentToken = token.block();
if ( currentToken == null ) {
log.info("token is null");
}
return Mono.just(s.toUpperCase());
}
}
Full ConnectController:
#Slf4j
#Controller
public class ConnectController {
#ConnectMapping("connect")
void connectShellClientAndAskForTelemetry(RSocketRequester requester,
#Payload String client) {
requester.rsocket()
.onClose()
.doFirst(() -> {
// Add all new clients to a client list
log.info("Client: {} CONNECTED.", client);
})
.doOnError(error -> {
// Warn when channels are closed by clients
log.warn("Channel to client {} CLOSED", client);
})
.doFinally(consumer -> {
// Remove disconnected clients from the client list
log.info("Client {} DISCONNECTED", client);
})
.subscribe();
}
}
RSocket Client:
#Component
#Slf4j
public class RSocketClient {
private static final MimeType SIMPLE_AUTH = MimeTypeUtils.parseMimeType(WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION.getString());
MimeType BEARER_AUTH =
MimeTypeUtils.parseMimeType(WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION.getString());
private static final String BEARER_TOKEN = "....";
private final RSocketRequester requester;
private RSocketStrategies rsocketStrategies;
public RSocketClient(RSocketRequester.Builder requesterBuilder,
#Qualifier("rSocketStrategies") RSocketStrategies strategies) {
this.rsocketStrategies = strategies;
SocketAcceptor responder = RSocketMessageHandler.responder(rsocketStrategies, new RSocketClientHandler());
requester = requesterBuilder
.setupRoute("connect")
.setupData("MyTestClient")
.setupMetadata(new BearerTokenMetadata(BEARER_TOKEN), BEARER_AUTH)
.rsocketStrategies(builder ->
builder.encoder(new BearerTokenAuthenticationEncoder()))
.rsocketConnector(connector -> connector.acceptor(responder))
.connectTcp("localhost", 7000)
.block();
requester.rsocket()
.onClose()
.doOnError(error -> log.warn("Connection CLOSED"))
.doFinally(consumer -> log.info("Client DISCONNECTED"))
.subscribe();
}
public void uppercase() {
String response = requester
.route("uppercase")
.metadata(BEARER_TOKEN, BEARER_AUTH)
.data("Hello")
.retrieveMono(String.class).block();
log.info(response);
}
}
I have done something very similar for Spring REST and it works fine but for RSocket the token is always null.
I assume you have started with https://spring.io/blog/2020/06/17/getting-started-with-rsocket-spring-security
I was able to get this working for my codebase using a different type than #Payload
#ConnectMapping
fun handle(requester: RSocketRequester, #AuthenticationPrincipal jwt: String) {
logger.debug("connected $jwt")
}
#MessageMapping("runCommand")
suspend fun runCommand(request: CommandRequest, rSocketRequester: RSocketRequester, #AuthenticationPrincipal jwt: String): Flow<CommandResponse> {
...
}
I am using Keycloak as jwt Issuer. Just follow up this git repo. Only thing that didn't work for me is
#CurrentUserProfile Mono<UserProfile> currentUserProfile
Solution for that w.r.t above git repo will be using either of the below
#CurrentUserProfile Mono<Jwt> currentUserProfile
or directly use
#AuthenticationPrincipal Jwt currentUserProfile
Hope this will work for you. Enjoy!
I've got a legacy app in java that can't use spring cloud. It uses a feign client to access a microservice throug the gateway.
Gateway and service are generated by jhipster 5.7.2 with OAuth2/OIDC option.
In my client, the a RequestInterceptor calls keycloak in order to get a token (direct access grant) and injects it int he header.
It's ok when I make a GET request, but I receive a 403 after POST or PUT request.
CORS is enabled on the gateway (but isn't used because the request isn't a cors request). I run it in dev mode.
Zuul routes seem to be ok.
I didn't change the config neither on the gateway nor on the service.
Does anybody have an idea ?
Below my feign client :
public interface SmartDocumentClient {
#RequestLine("GET /api/ebox/test")
//#Headers("Content-Type: application/json")
public ResponseEntity<HasEboxResponse> test();
#RequestLine("POST /api/ebox/test")
#Headers("Content-Type: application/json")
public ResponseEntity<HasEboxResponse> testPost(HasEboxRequest request);
#RequestLine("PUT /api/ebox/test")
#Headers("Content-Type: application/json")
public ResponseEntity<HasEboxResponse> testPut(HasEboxRequest request); }
My client config :
T client = Feign.builder()
.contract(new feign.Contract.Default()) //annotation openfeign pour éviter bug d'upload avec SpringMvc
.client(new OkHttpClient())
.encoder(new FormEncoder(new GsonEncoder())) //pour gérer le formData
.decoder(new ResponseEntityDecoder(new ResponseEntityDecoder(new CustomFileDecoder(new CustomGsonDecoder()))))
.requestInterceptor(interceptor)
.options(new Request.Options(timeout, timeout))
.target(SmartDocumentClient, url);
The interceptor :
public class GedRequestInterceptor implements RequestInterceptor {
public static final String AUTHORIZATION = "Authorization";
public static final String BEARER = "Bearer";
private String authUrl;
private String user;
private String password;
private String clientId;
private String clientSecret;
private RestTemplate restTemplate;
private CustomOAuth2ClientContext oAuth2ClientContext;
public GedRequestInterceptor(String authUrl, String user, String password, String clientId, String clientSecret) {
super();
this.authUrl = authUrl;
this.user = user;
this.password = password;
this.clientId = clientId;
this.clientSecret = clientSecret;
restTemplate = new RestTemplate();
//oAuth2ClientContext = new DefaultOAuth2ClientContext();
}
#Override
public void apply(RequestTemplate template) {
// demander un token à keycloak et le joindre à la request
Optional<String> token = getToken();
if (token.isPresent()) {
template.header(HttpHeaders.ORIGIN, "localhost");
template.header(AUTHORIZATION, String.format("%s %s", BEARER, token.get()));
}
}
private Optional<String> getToken() {
if (oAuth2ClientContext.getAccessToken() == null || oAuth2ClientContext.getAccessToken().isExpired()) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("client_id", this.clientId);
map.add("client_secret", this.clientSecret);
map.add("grant_type", "password"); // client_credentials //password
map.add("username", this.user);
map.add("password", this.password);
oAuth2ClientContext.setAccessToken(askToken(map));
}
if (oAuth2ClientContext.getAccessToken() != null){
return Optional.ofNullable(oAuth2ClientContext.getAccessToken().getValue());
} else {
return Optional.empty();
}
}
private CustomOAuth2AccessToken askToken( MultiValueMap<String, String> map) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(map, headers);
ResponseEntity<CustomOAuth2AccessToken> response = restTemplate.postForEntity(
this.authUrl, request, CustomOAuth2AccessToken.class);
if (response != null && response.hasBody()) {
return response.getBody();
} else {
return null;
}
}
}
And finally the ressource :
#RestController
#RequestMapping("/api")
public class DocumentResource {
private static String TMP_FILE_PREFIX = "smartdoc_tmp";
public DocumentResource() {
}
#GetMapping("/ebox/test")
public ResponseEntity<HasEboxResponse> test() {
return ResponseEntity.ok(new HasEboxResponse());
}
#PostMapping("/ebox/test")
public ResponseEntity<HasEboxResponse> testPost(#RequestBody HasEboxRequest request) {
return ResponseEntity.ok(new HasEboxResponse());
}
#PutMapping("/ebox/test")
public ResponseEntity<HasEboxResponse> testPut(#RequestBody HasEboxRequest request) {
return ResponseEntity.ok(new HasEboxResponse());
}
}
Thanks !
The problem was in the spring security config. WebSecurity didn't allow to call urls like "[SERVICE_NAME]/api" without authentication. I added a rule to allow acces to some urls. If an access token is in the header, it will be forward to the service by zuul.
#Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/ext/*/api/**") // allow calls to services, redirect by zuul
.antMatchers(HttpMethod.OPTIONS, "/**")
.antMatchers("/app/**/*.{js,html}")
.antMatchers("/i18n/**")
.antMatchers("/content/**")
.antMatchers("/swagger-ui/index.html")
.antMatchers("/test/**");
}
In order to call others services by UI and let inject access token by the gateway, I defined two groups of routes in my zuul config,
routes:
myservice:
path: /myservice/**
serviceId: myservice
myservice_ext:
path: /ext/myservice/**
serviceId: myservice
/ext/myService... : referencing services and don't and ignore by spring secu
/myService... : referencing services but handled by spring secu