I've been trying to set up a Keycloak locally with docker to be able to login to our application with SAML 2.0.
Versions used:
Keyloak 19.0.3
Spring Boot 2.7.3
When I call an REST endpoint of the application, I am correctly redirected to Keycloak (redirect to http://localhost:8085/realms/PocRealm/protocol/saml). But I don't get a login form but the message: "We are sorry ... Invalid Request" .
I then see the following entry in the Docker console logs.
2022-10-07 12:22:41,972 WARN [org.keycloak.events] (executor-thread-104) type=LOGIN_ERROR, realmId=e026f301-c74b-4247-bb0a-58cdb651ae00, clientId=null, userId=null, ipAddress=172.17.0.1, error=client_not_found, reason=Cannot_match_source_hash
These are my configurations:
application.properties
# Spring Server Settings
server.port=8081
#Keycloak Settings
keycloak.auth-server-url=http://localhost:8085
keycloak.realm=PocRealm
keycloak.resource=pocApp
keycloak.principal-attribute=preferred_username
#SAML Settings
spring.security.saml2.relyingparty.registration.keycloak.signing.credentials[0].private-key-location=classpath:credentials/myKey.key
spring.security.saml2.relyingparty.registration.keycloak.signing.credentials[0].certificate-location=classpath:credentials/myCert.crt
spring.security.saml2.relyingparty.registration.keycloak.assertingparty.metadata-uri=http://localhost:8085/realms/PocRealm/protocol/saml/descriptor
KeycloakConfig.java
#Configuration
public class KeycloakConfig {
#Bean
public KeycloakSpringBootConfigResolver keycloakConfigResolver() {
return new KeycloakSpringBootConfigResolver();
}
}
SecurityConfig.java
#KeycloakConfiguration
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter
{
#Autowired
public void configureGlobal(AuthenticationManagerBuilder authenticationManagerBuilder)
{
SimpleAuthorityMapper simpleAuthorityMapper = new SimpleAuthorityMapper();
simpleAuthorityMapper.setPrefix("ROLE_");
KeycloakAuthenticationProvider keycloakAuthenticationProvider =
keycloakAuthenticationProvider();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(simpleAuthorityMapper);
authenticationManagerBuilder.authenticationProvider(keycloakAuthenticationProvider);
}
#Bean
#Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy ()
{
return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
}
#Bean
#Override
#ConditionalOnMissingBean(HttpSessionManager.class)
protected HttpSessionManager httpSessionManager()
{
return new HttpSessionManager();
}
#Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(groupsConverter());
// #formatter:off
httpSecurity
.authorizeHttpRequests(authorize -> authorize
.mvcMatchers("/favicon.ico").permitAll()
.anyRequest().authenticated()
)
.saml2Login(saml2 -> saml2
.authenticationManager(new ProviderManager(authenticationProvider))
)
.saml2Logout(withDefaults());
// #formatter:on
}
private Converter<OpenSaml4AuthenticationProvider.ResponseToken, Saml2Authentication> groupsConverter() {
Converter<ResponseToken, Saml2Authentication> delegate =
OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter();
return (responseToken) -> {
Saml2Authentication authentication = delegate.convert(responseToken);
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
List<String> groups = principal.getAttribute("groups");
Set<GrantedAuthority> authorities = new HashSet<>();
if (groups != null) {
groups.stream().map(SimpleGrantedAuthority::new).forEach(authorities::add);
} else {
authorities.addAll(authentication.getAuthorities());
}
return new Saml2Authentication(principal, authentication.getSaml2Response(), authorities);
};
}
}
I don't see the problem and even after hours of research I can't get anywhere at this point and maybe someone here can help me? Maybe there is a better approach in general? (OpenID-Connect instead of SAML is unfortunately not an option)
Thank you
Related
I'm working on modernizing monolithic application to be microservice based application supporting multi tenancy using Spring boot, Keycloak 17, the configuration is Keycloak configuration file depending on the path referring to this example
For me it it working, and can load the deployments from json, login, below is the url for the application and I'm parsing branch1 after "tenant" without issues
http://localhost:8100/tenant/branch1/
The main issue is rendering css and JS files which is containing tenant name knwoing that I'm using sing WAR
with multiple realms
http://localhost:8100/tenant/branch1/resources/bootstrap/js/bootstrap.min.js --> return 404 which is not exist
Actual code for including static contents
in The jsp files I'm reading css/js files as before <link rel="stylesheet" href="resources/bootstrap/css/bootstrap.min.css">
keycloal json file example
{"realm": "branch1",
"auth-server-url": "http://localhost:8181/",
"ssl-required": "external",
"resource": "app",
"public-client": true,
"confidential-port": 0,
"principal-attribute": "preferred_username"}
Please advise
rendering static content
is there any guidance after authentication to return one URL without tenant/branch1 specially I'm using CurrentTenantIdentifierResolver inside my application
#ConditionalOnProperty(prefix = "keycloak.config", name = "resolver", havingValue = "path")
public class PathBasedConfigResolver implements KeycloakConfigResolver {
private final ConcurrentHashMap<String, KeycloakDeployment> cache = new ConcurrentHashMap<>();
#SuppressWarnings("unused")
private static AdapterConfig adapterConfig;
#Override
public KeycloakDeployment resolve(OIDCHttpFacade.Request request) {
System.out.println("inside resolve :: ");
String realm = SubdomainUtils.obtainTenantFromSubdomain(request.getURI());
if (realm.contains("?")) {
realm = realm.split("\\?")[0];
}
if (!cache.containsKey(realm)) {
InputStream is = this.getClass().getResourceAsStream("/" + realm + "-keycloak.json");
cache.put(realm, KeycloakDeploymentBuilder.build(is));
}
return cache.get(realm);
}
static void setAdapterConfig(AdapterConfig adapterConfig) {
PathBasedConfigResolver.adapterConfig = adapterConfig;
}
}
public class SpringKeycloakSecurityConfiguration {
#DependsOn("keycloakConfigResolver")
#KeycloakConfiguration
#ConditionalOnProperty(name = "keycloak.enabled", havingValue = "true", matchIfMissing = true)
public static class KeycloakConfigurationAdapter extends KeycloakWebSecurityConfigurerAdapter {
/**
* Registers the KeycloakAuthenticationProvider with the authentication manager.
*/
#Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
SimpleAuthorityMapper soa = new SimpleAuthorityMapper();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(soa);
auth.authenticationProvider(keycloakAuthenticationProvider);
}
/**
* Defines the session authentication strategy.
*/
#Bean
#Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
// required for bearer-only applications.
// return new NullAuthenticatedSessionStrategy();
return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
}
#Override
protected AuthenticationEntryPoint authenticationEntryPoint() throws Exception {
return new MultitenantKeycloakAuthenticationEntryPoint(adapterDeploymentContext());
}
#Override
protected KeycloakAuthenticationProcessingFilter keycloakAuthenticationProcessingFilter() throws Exception {
KeycloakAuthenticationProcessingFilter filter = new KeycloakAuthenticationProcessingFilter(
authenticationManager(), new AntPathRequestMatcher("/tenant/*/sso/login"));
filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy());
return filter;
}
#SuppressWarnings({ "rawtypes", "unchecked" })
#Bean
public FilterRegistrationBean keycloakAuthenticationProcessingFilterRegistrationBean(
KeycloakAuthenticationProcessingFilter filter) {
FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
registrationBean.setEnabled(false);
return registrationBean;
}
#SuppressWarnings({ "rawtypes", "unchecked" })
#Bean
public FilterRegistrationBean keycloakPreAuthActionsFilterRegistrationBean(
KeycloakPreAuthActionsFilter filter) {
FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
registrationBean.setEnabled(false);
return registrationBean;
}
#SuppressWarnings({ "rawtypes", "unchecked" })
#Bean
public FilterRegistrationBean keycloakAuthenticatedActionsFilterBean(
KeycloakAuthenticatedActionsFilter filter) {
FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
registrationBean.setEnabled(false);
return registrationBean;
}
#SuppressWarnings({ "rawtypes", "unchecked" })
#Bean
public FilterRegistrationBean keycloakSecurityContextRequestFilterBean(
KeycloakSecurityContextRequestFilter filter) {
FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
registrationBean.setEnabled(false);
return registrationBean;
}
#Bean
#Override
#ConditionalOnMissingBean(HttpSessionManager.class)
protected HttpSessionManager httpSessionManager() {
return new HttpSessionManager();
}
/**
* Configuration spécifique à keycloak (ajouts de filtres, etc)
*
* #param http
* #throws Exception
*/
#Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
// use previously declared bean
.sessionAuthenticationStrategy(sessionAuthenticationStrategy())
// keycloak filters for securisation
.and().addFilterBefore(keycloakPreAuthActionsFilter(), LogoutFilter.class)
.addFilterBefore(keycloakAuthenticationProcessingFilter(), X509AuthenticationFilter.class)
.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint())
.and().logout().addLogoutHandler(keycloakLogoutHandler()).logoutUrl("/tenant/*/logout")
.logoutSuccessHandler(
// logout handler for API
(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) -> response.setStatus(HttpServletResponse.SC_OK))
.and().authorizeRequests().antMatchers("mobileservlet/**").permitAll().antMatchers("**/favicon.ico")
.permitAll().antMatchers("/error").permitAll().antMatchers("/login.go").permitAll()
.antMatchers("/resources/*").permitAll().anyRequest().authenticated();
}
#Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList(HttpMethod.OPTIONS.name(), "GET", "POST"));
configuration.setAllowedHeaders(
Arrays.asList("Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
}
public class MultitenantKeycloakAuthenticationEntryPoint extends KeycloakAuthenticationEntryPoint {
public MultitenantKeycloakAuthenticationEntryPoint(AdapterDeploymentContext adapterDeploymentContext) {
super(adapterDeploymentContext);
}
public MultitenantKeycloakAuthenticationEntryPoint(AdapterDeploymentContext adapterDeploymentContext, RequestMatcher apiRequestMatcher) {
super(adapterDeploymentContext, apiRequestMatcher);
}
#Override
protected void commenceLoginRedirect(HttpServletRequest request, HttpServletResponse response) throws IOException {
System.out.println("inside commenceLoginRedirect :: ");
String path = request.getRequestURI();
int multitenantIndex = path.indexOf("tenant/");
if (multitenantIndex == -1) {
throw new IllegalStateException("Not able to resolve the realm from the request path!");
}
String realm = path.substring(path.indexOf("tenant/")).split("/")[1];
if (realm.contains("?")) {
realm = realm.split("\\?")[0];
}
String contextAwareLoginUri = request.getContextPath() + "/tenant/" + realm + DEFAULT_LOGIN_URI;
response.sendRedirect(contextAwareLoginUri);
}
}
Bad news, the Keycloak adapters for spring you are using are very deprecated. Don't use it.
Better news, I host spring-boot starters for resource-servers which support multi-tenancy: accept identities issued by more than just one issuer (as many realms as you need in your case) and retrieve "roles" from realms and clients with the mapping you want (control case and prefix). It also enables you to configure "public" routes and CORS configuration from preperties file (plus a few more things).
Configuration for realm1 and other-realm both used by two clients (some client and other-client) is as simple as:
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<!-- replace "webflux" with "webmvc" if your app is a servlet -->
<!-- replace "jwt" with "introspecting" to use token introspection instead of JWT decoding -->
<artifactId>spring-addons-webflux-jwt-resource-server</artifactId>
<!-- this version is to be used with spring-boot 3.0.0-RC2, use 5.x for spring-boot 2.6.x or before -->
<version>6.0.5</version>
</dependency>
#EnableMethodSecurity
public static class WebSecurityConfig { }
com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/realm1
com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,ressource_access.some-client.roles,ressource_access.other-client.roles
com.c4-soft.springaddons.security.issuers[1].location=https://localhost:8443/realms/other-realm
com.c4-soft.springaddons.security.issuers[1].authorities.claims=realm_access.roles,ressource_access.some-client.roles,ressource_access.other-client.roles
com.c4-soft.springaddons.security.cors[0].path=/some-api
I'm using the following LDAP configuration that makes use of GlobalAuthenticationConfigurerAdapter:
public static class AuthenticationConfiguration extends GlobalAuthenticationConfigurerAdapter {
#Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
for (String ldapUrl : ldapUrls) { //I have multiple LDAP servers that must be included
auth.ldapAuthentication()
.userSearchFilter("...")
.contextSource()
.url(ldapUrl + ldapBase)
.managerDn(ldapUsername)
.managerPassword(ldapPassword);
}
}
}
}
As per the following docs, there is a new way of providing LDAP authentication beans:
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter#ldap-authentication > LDAP Authentication
#Bean
AuthenticationManager ldapAuthenticationManager(
BaseLdapPathContextSource contextSource) {
LdapBindAuthenticationManagerFactory factory =
new LdapBindAuthenticationManagerFactory(contextSource);
factory.setUserDnPatterns("uid={0},ou=people");
factory.setUserDetailsContextMapper(new PersonContextMapper());
return factory.createAuthenticationManager();
}
Question: how can I now transform my old configuration (that includes multiple LDAP urls) into the new beans?
I mean, where can I get the BaseLdapPathContextSource from, and feed it with my ldap login credentials and base urls?
As AuthenticationManager only has a single instance per SecurityFilterChain i would think you have to create a set of AuthenticationProvider instead. and add those to you SecurityFilterChain.
Below code is a mock as i'm not sure if LdapContextSource needs more properties to be set and if you want base UserDetailsServiceLdapAuthoritiesPopulator or something custom.
#Bean
public SecurityFilterChain filterChain(HttpSecurity http, List<LdapAuthenticator> ldapAuthenticators) throws Exception
{
for(LdapAuthenticator authenticator : ldapAuthenticators)
{
LdapAuthoritiesPopulator ldapAuthoritiesPopulator = new UserDetailsServiceLdapAuthoritiesPopulator(userDetails);
http.authenticationProvider(new LdapAuthenticationProvider(authenticator, ldapAuthoritiesPopulator));
}
}
#Bean
public List<BindAuthenticator> ldapAuthenticator()
{
List<BindAuthenticator> authenticators = new ArrayList<>();
for (String ldapUrl : ldapUrls)
{
LdapContextSource contextSource = new LdapContextSource();
contextSource.setUrl(ldapUrl);
BindAuthenticator authenticator = new BindAuthenticator(contextSource);
authenticator.setUserSearch(new FilterBasedLdapUserSearch("ou=people", "(uid={0})", ldapContextSource));
authenticators.add(authenticator);
}
return authenticators;
}
I'm using Keycloak to secure my Spring boot backend.
Dependencies:
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-boot-2-adapter</artifactId>
<version>12.0.3</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-tomcat7-adapter-dist</artifactId>
<version>12.0.3</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-security-adapter</artifactId>
<version>12.0.3</version>
</dependency>
Security config:
#Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry expressionInterceptUrlRegistry = http.cors()
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests();
expressionInterceptUrlRegistry = expressionInterceptUrlRegistry.antMatchers("/iam/accounts/promoters*").hasRole("PROMOTER");
expressionInterceptUrlRegistry.anyRequest().permitAll();
}
Everything work fine!
But now I add a new section in keycloak token "roles" and I need to somehow extend keycloak jwt class in my Spring boot and write some code to parse and store the roles information to SecurityContext. Could you Guy please tell me how to archive the goal?
First, extends keycloak AccessToken:
#Data
static class CustomKeycloakAccessToken extends AccessToken {
#JsonProperty("roles")
protected Set<String> roles;
}
Then:
#KeycloakConfiguration
#EnableGlobalMethodSecurity(prePostEnabled = true)
public class KeycloakSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
#Override
protected KeycloakAuthenticationProvider keycloakAuthenticationProvider() {
return new KeycloakAuthenticationProvider() {
#Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
KeycloakAuthenticationToken token = (KeycloakAuthenticationToken) authentication;
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
for (String role : ((CustomKeycloakAccessToken)((KeycloakPrincipal)token.getPrincipal()).getKeycloakSecurityContext().getToken()).getRoles()) {
grantedAuthorities.add(new KeycloakRole(role));
}
return new KeycloakAuthenticationToken(token.getAccount(), token.isInteractive(), new SimpleAuthorityMapper().mapAuthorities(grantedAuthorities));
}
};
}
/**
* Use NullAuthenticatedSessionStrategy for bearer-only tokens. Otherwise, use
* RegisterSessionAuthenticationStrategy.
*/
#Bean
#Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new NullAuthenticatedSessionStrategy();
}
#Override
protected KeycloakAuthenticationProcessingFilter keycloakAuthenticationProcessingFilter() throws Exception {
KeycloakAuthenticationProcessingFilter filter = new KeycloakAuthenticationProcessingFilter(authenticationManagerBean());
filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy());
filter.setRequestAuthenticatorFactory(new SpringSecurityRequestAuthenticatorFactory() {
#Override
public RequestAuthenticator createRequestAuthenticator(HttpFacade facade,
HttpServletRequest request, KeycloakDeployment deployment, AdapterTokenStore tokenStore, int sslRedirectPort) {
return new SpringSecurityRequestAuthenticator(facade, request, deployment, tokenStore, sslRedirectPort) {
#Override
protected BearerTokenRequestAuthenticator createBearerTokenAuthenticator() {
return new BearerTokenRequestAuthenticator(deployment) {
#Override
protected AuthOutcome authenticateToken(HttpFacade exchange, String tokenString) {
log.debug("Verifying access_token");
if (log.isTraceEnabled()) {
try {
JWSInput jwsInput = new JWSInput(tokenString);
String wireString = jwsInput.getWireString();
log.tracef("\taccess_token: %s", wireString.substring(0, wireString.lastIndexOf(".")) + ".signature");
} catch (JWSInputException e) {
log.errorf(e, "Failed to parse access_token: %s", tokenString);
}
}
try {
TokenVerifier<CustomKeycloakAccessToken> tokenVerifier = AdapterTokenVerifier.createVerifier(tokenString, deployment, true, CustomKeycloakAccessToken.class);
// Verify audience of bearer-token
if (deployment.isVerifyTokenAudience()) {
tokenVerifier.audience(deployment.getResourceName());
}
token = tokenVerifier.verify().getToken();
} catch (VerificationException e) {
log.debug("Failed to verify token");
challenge = challengeResponse(exchange, OIDCAuthenticationError.Reason.INVALID_TOKEN, "invalid_token", e.getMessage());
return AuthOutcome.FAILED;
}
if (token.getIssuedAt() < deployment.getNotBefore()) {
log.debug("Stale token");
challenge = challengeResponse(exchange, OIDCAuthenticationError.Reason.STALE_TOKEN, "invalid_token", "Stale token");
return AuthOutcome.FAILED;
}
boolean verifyCaller;
if (deployment.isUseResourceRoleMappings()) {
verifyCaller = token.isVerifyCaller(deployment.getResourceName());
} else {
verifyCaller = token.isVerifyCaller();
}
surrogate = null;
if (verifyCaller) {
if (token.getTrustedCertificates() == null || token.getTrustedCertificates().isEmpty()) {
log.warn("No trusted certificates in token");
challenge = clientCertChallenge();
return AuthOutcome.FAILED;
}
// for now, we just make sure Undertow did two-way SSL
// assume JBoss Web verifies the client cert
X509Certificate[] chain = new X509Certificate[0];
try {
chain = exchange.getCertificateChain();
} catch (Exception ignore) {
}
if (chain == null || chain.length == 0) {
log.warn("No certificates provided by undertow to verify the caller");
challenge = clientCertChallenge();
return AuthOutcome.FAILED;
}
surrogate = chain[0].getSubjectDN().getName();
}
log.debug("successful authorized");
return AuthOutcome.AUTHENTICATED;
}
};
}
};
}
});
return filter;
}
}
I didn't understand why do you need extend Keycloak Token. The roles already there are in Keycloak Token. I will try explain how to access it, the Keycloak have two levels for roles, 1) Realm level and 2) Application (Client) level, by default your Keycloak Adapter use realm level, to use application level you need setting the propertie keycloak.use-resource-role-mappings with true in your application.yml
How to create roles in realm
enter image description here
How to creare roles in client
enter image description here
User with roles ADMIN (realm) and ADD_USER (application)
enter image description here
To have access roles you can use KeycloakAuthenticationToken class in your Keycloak Adapter, you can try invoke the following method:
...
public ResponseEntity<Object> getUsers(final KeycloakAuthenticationToken authenticationToken) {
final AccessToken token = authenticationToken.getAccount().getKeycloakSecurityContext().getToken();
final Set<String> roles = token.getRealmAccess().getRoles();
final Map<String, AccessToken.Access> resourceAccess = token.getResourceAccess();
...
}
...
To protect any router using Spring Security you can use this annotation, example below:
#PreAuthorize("hasRole('ADMIN')")
#GetMapping("/users")
public ResponseEntity<Object> getUsers(final KeycloakAuthenticationToken token) {
return ResponseEntity.ok(service.getUsers());
}
Obs: The keycloak.use-resource-role-mappings set up using #PreAuthorize Annotation. If set to true, #PreAuthorize checks roles in token.getRealmAccess().getRoles(), if false it checks roles in token.getResourceAccess().
If you want add any custom claim in token, let me know that I can explain better.
I put here how I set up my Keycloak Adapter and the properties in my application.yml:
SecurityConfig.java
...
#KeycloakConfiguration
#EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
#Value("${project.cors.allowed-origins}")
private String origins = "";
#Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
auth.authenticationProvider(keycloakAuthenticationProvider);
}
#Bean
public KeycloakSpringBootConfigResolver keycloakConfigResolver() {
return new KeycloakSpringBootConfigResolver();
}
#Bean
#Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new NullAuthenticatedSessionStrategy();
}
#Override
protected KeycloakAuthenticationProcessingFilter keycloakAuthenticationProcessingFilter() throws Exception {
KeycloakAuthenticationProcessingFilter filter = new KeycloakAuthenticationProcessingFilter(this.authenticationManagerBean());
filter.setSessionAuthenticationStrategy(this.sessionAuthenticationStrategy());
filter.setAuthenticationFailureHandler((request, response, exception) -> {
response.addHeader("Access-Control-Allow-Origin", origins);
if (!response.isCommitted()) {
response.sendError(401, "Unable to authenticate using the Authorization header");
} else if (200 <= response.getStatus() && response.getStatus() < 300) {
throw new RuntimeException("Success response was committed while authentication failed!", exception);
}
});
return filter;
}
#Override
protected void configure(final HttpSecurity http) throws Exception {
super.configure(http);
http.csrf()
.disable()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "**").permitAll()
.antMatchers("/s/**").authenticated()
.anyRequest().permitAll();
}
}
application.yml
..
keycloak:
enabled: true
auth-server-url: http://localhost:8080/auth
resource: myclient
realm: myrealm
bearer-only: true
principal-attribute: preferred_username
use-resource-role-mappings: true
..
I'm using Keycloak as my OAuth2 Authorization Server and I configured an OAuth2 Resource Server for Multitenancy following this official example on GitHub.
The current Tenant is resolved considering the Issuer field of the JWT token.
Hence the token is verified against the JWKS exposed at the corresponding OpenID Connect well known endpoint.
This is my Security Configuration:
#EnableWebSecurity
#RequiredArgsConstructor
#EnableAutoConfiguration(exclude = UserDetailsServiceAutoConfiguration.class)
public class OrganizationSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final TenantService tenantService;
private List<Tenant> tenants;
#PostConstruct
public void init() {
this.tenants = this.tenantService.findAllWithRelationships();
}
#Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests().anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.authenticationManagerResolver(new MultiTenantAuthenticationManagerResolver(this.tenants));
}
}
and this is my custom AuthenticationManagerResolver:
public class MultiTenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
private final AuthenticationManagerResolver<HttpServletRequest> resolver;
private List<Tenant> tenants;
public MultiTenantAuthenticationManagerResolver(List<Tenant> tenants) {
this.tenants = tenants;
List<String> trustedIssuers = this.tenants.stream()
.map(Tenant::getIssuers)
.flatMap(urls -> urls.stream().map(URL::toString))
.collect(Collectors.toList());
this.resolver = new JwtIssuerAuthenticationManagerResolver(trustedIssuers);
}
#Override
public AuthenticationManager resolve(HttpServletRequest context) {
return this.resolver.resolve(context);
}
}
Now, because of the design of org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver.TrustedIssuerJwtAuthenticationManagerResolver
which is private, the only way I can think in order to extract a custom principal is to reimplement everything that follows:
TrustedIssuerJwtAuthenticationManagerResolver
the returned AuthenticationManager
the AuthenticationConverter
the CustomAuthenticationToken which extends JwtAuthenticationToken
the CustomPrincipal
To me it seems a lot of Reinventing the wheel, where my only need would be to have a custom Principal.
The examples that I found don't seem to suit my case since they refer to OAuth2Client or are not tought for Multitenancy.
https://www.baeldung.com/spring-security-oauth-principal-authorities-extractor
How to extend OAuth2 principal
Do I really need to reimplement all such classes/interfaes or is there a smarter approach?
This is how I did it, without reimplementing a huge amount of classes. This is without using a JwtAuthenticationToken however.
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...
#Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver()));
}
#Bean
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver() {
List<String> issuers = ... // get this from list of tennants or config, whatever
Predicate<String> trustedIssuer = issuers::contains;
Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();
AuthenticationManagerResolver<String> resolver = (String issuer) -> {
if (trustedIssuer.test(issuer)) {
return authenticationManagers.computeIfAbsent(issuer, k -> {
var jwtDecoder = JwtDecoders.fromIssuerLocation(issuer);
var provider = new JwtAuthenticationProvider(jwtDecoder);
provider.setJwtAuthenticationConverter(jwtAuthenticationService::loadUserByJwt);
return provider::authenticate;
});
}
return null;
};
return new JwtIssuerAuthenticationManagerResolver(resolver);
}
}
#Service
public class JwtAuthenticationService {
public AbstractAuthenticationToken loadUserByJwt(Jwt jwt) {
UserDetails userDetails = ... // or your choice of principal
List<GrantedAuthority> authorities = ... // extract from jwt or db
...
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
}
}
I'm trying to build a user-service to access keycloak with spring-boot and the keycloak-admin-client.
edit: I should mention that run the service and keycloak in different docker containers, I think that might be the problem.
My AUTH_SERVER is set to keycloak:8080, and I have it to redirect to localhost in my hostfile.
edit2: I managed to get the token through swagger, but the user-creation still ends with a 403 Forbidden, although the exact same code works if run outside of swagger. Seems like a problem with my spring-boot or my swagger.
Stragely enough, I can get a token just fine.
I want to create a user and provide a login endpoint, where another service can login a user with username/password and get a token back.
The code for user creation works if I run it outside of swagger in a main method, and I can get a token via postman. (now also through swagger)
But with swagger-ui, I get a "403 Forbidden" when trying to create a user.
I have tried both the Postrequest via resttemplate and through the admin-cli of keycloak.
Both work when run independently of swagger and both dont work with swagger.
#PostMapping(path = "new")
public ResponseEntity<String> addUser(UserData userData) {
UserRepresentation user = new UserRepresentation();
user.setEnabled(true);
user.setUsername(userData.getUsername());
user.setFirstName(userData.getFirstName());
user.setLastName(userData.getLastName());
RealmResource realmResource = getRealmResource();
UsersResource userResource = realmResource.users();
Response response = userResource.create(user);
log.info("Response: " + response.getStatusInfo());
return new ResponseEntity<>("User created with userId: " + userData.getBusinessEntityId(),
HttpStatus.OK);
}
My securityconfig:
/*
Submits the KeycloakAuthenticationProvider to the AuthenticationManager
*/
#Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
auth.authenticationProvider(keycloakAuthenticationProvider);
}
#Bean
public KeycloakSpringBootConfigResolver KeycloakConfigResolver() {
return new KeycloakSpringBootConfigResolver();
}
#Bean
#Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
}
#Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.authorizeRequests()
.antMatchers("/api/v1/user/admin").hasRole("admin")
.antMatchers("/api/v1/user/vendor").hasRole("vendor")
// .antMatchers("/api/v1/user/customer").hasRole("customer")
.anyRequest().permitAll();
}
My Swaggerconfig:
#Bean
public Docket apiDocumentation() {
return new Docket(DocumentationType.SWAGGER_2)
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.any())
.build()
.securitySchemes(Arrays.asList(securityScheme()))
.securityContexts(Arrays.asList(securityContext()));
}
private SecurityScheme securityScheme() {
return new OAuthBuilder()
.name("spring_oauth")
.grantTypes(grantTypes())
.build();
}
private SecurityContext securityContext() {
return SecurityContext.builder()
.securityReferences(Arrays.asList(new SecurityReference("spring_oauth", new AuthorizationScope[]{})))
.forPaths(PathSelectors.any())
.build();
}
private List<GrantType> grantTypes() {
GrantType grantType = new ClientCredentialsGrant(AUTH_SERVER + "/realms/User-Service-Realm/protocol/openid-connect/token");
return Arrays.asList(grantType);
}
#Bean
public SecurityConfiguration security() {
return SecurityConfigurationBuilder.builder()
.realm(REALM)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.scopeSeparator(" ")
.useBasicAuthenticationWithAccessCodeGrant(true)
.build();
}
My Keycloak settings:
I could manage it to work in a client credential grant.
You may want to try it with the following configuration instead.
private SecurityScheme securityScheme() {
return new OAuthBuilder()
.name("spring_oauth")
.grantTypes(grantTypes())
.build();
}
private SecurityContext securityContext() {
return SecurityContext.builder()
.securityReferences(Arrays.asList(new SecurityReference("spring_oauth", new AuthorizationScope[] {})))
.forPaths(PathSelectors.regex("/api.*"))
.build();
}
private List<GrantType> grantTypes() {
GrantType grantType = new ClientCredentialsGrant(authTokenURL);
return Arrays.asList(grantType);
}
I found out the solution:
I annotated the Requests in my RestController as PostRequests since thats whats specified in the keycloak docs and what makes sense.
After changing them to GetRequests, they work now.