From bba0cc5a610c37989fd4af7290c5fa54917b8bdd Mon Sep 17 00:00:00 2001 From: Julien Chanaud Date: Fri, 11 Jun 2021 21:45:35 +0200 Subject: [PATCH] feat(auth): externalizable source of truth for roles and attributes (#678) Co-authored-by: Julien Chanaud --- README.md | 109 ++++++++ build.gradle | 3 + .../akhq/controllers/AbstractController.java | 8 +- .../BasicAuthAuthenticationProvider.java | 52 ++-- .../LdapContextAuthenticationMapper.java | 33 +-- .../akhq/modules/OidcUserDetailsMapper.java | 134 ++++------ .../SecuredAnnotationRuleWithDefault.java | 6 +- .../akhq/repositories/ConnectRepository.java | 7 +- .../repositories/ConsumerGroupRepository.java | 6 +- .../akhq/repositories/TopicRepository.java | 6 +- .../java/org/akhq/utils/ClaimProvider.java | 42 ++++ .../org/akhq/utils/DefaultGroupUtils.java | 60 +++++ .../org/akhq/utils/GroovyClaimProvider.java | 44 ++++ .../utils/LocalSecurityClaimProvider.java | 141 +++++++++++ .../org/akhq/utils/RestApiClaimProvider.java | 18 ++ .../java/org/akhq/utils/UserGroupUtils.java | 93 ------- .../org/akhq/configs/UserGroupUtilsTest.java | 73 ------ .../BasicAuthAuthenticationProviderTest.java | 20 +- .../akhq/modules/GroovyClaimProviderTest.java | 47 ++++ .../OidcAuthenticationProviderTest.java | 233 ++++++++++++++++++ .../modules/OidcUserDetailsMapperTest.java | 154 ------------ .../resources/application-filterregex.yml | 15 -- src/test/resources/application-groovy.yml | 37 +++ src/test/resources/application-oidc.yml | 136 ++++++++++ src/test/resources/application.yml | 2 +- 25 files changed, 997 insertions(+), 482 deletions(-) create mode 100644 src/main/java/org/akhq/utils/ClaimProvider.java create mode 100644 src/main/java/org/akhq/utils/DefaultGroupUtils.java create mode 100644 src/main/java/org/akhq/utils/GroovyClaimProvider.java create mode 100644 src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java create mode 100644 src/main/java/org/akhq/utils/RestApiClaimProvider.java delete mode 100644 src/main/java/org/akhq/utils/UserGroupUtils.java delete mode 100644 src/test/java/org/akhq/configs/UserGroupUtilsTest.java create mode 100644 src/test/java/org/akhq/modules/GroovyClaimProviderTest.java create mode 100644 src/test/java/org/akhq/modules/OidcAuthenticationProviderTest.java delete mode 100644 src/test/java/org/akhq/modules/OidcUserDetailsMapperTest.java delete mode 100644 src/test/resources/application-filterregex.yml create mode 100644 src/test/resources/application-groovy.yml create mode 100644 src/test/resources/application-oidc.yml diff --git a/README.md b/README.md index 21d7d57d7..ce3255458 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,8 @@ Define groups with specific roles for your users * `attributes.connects-filter-regexp`: Regexp list to filter Connect tasks available for current group * `attributes.consumer-groups-filter-regexp`: Regexp list to filter Consumer Groups available for current group +:warning: `topics-filter-regexp`, `connects-filter-regexp` and `consumer-groups-filter-regexp` are only used when listing resources. +If you have `topics/create` or `connect/create` roles and you try to create a resource that doesn't follow the regexp, that resource **WILL** be created. 3 defaults group are available : - `admin` with all right @@ -612,6 +614,113 @@ akhq: The username field can be any string field, the roles field has to be a JSON array. +### External roles and attributes mapping + +If you managed which topics (or any other resource) in an external system, you have access to 2 more implementations mechanisms to map your authenticated user (from either Local, LDAP or OIDC Authent) into AKHQ roles and attributes: + +If you use this mechanism, keep in mind it will take the local user's groups for local Auth, and the external groups for LDAP/OIDC (ie. this will NOT do the mapping between LDAP/OIDC and local groups) + +**Default configuration-based** +This is the current implementation and the default one (doesn't break compatibility) +````yaml +akhq: + security: + default-group: no-roles + groups: + reader: + roles: + - topic/read + attributes: + topics-filter-regexp: [".*"] + no-roles: + roles: [] + ldap: # LDAP users/groups to AKHQ groups mapping + oidc: # OIDC users/groups to AKHQ groups mapping +```` + +**REST API** +````yaml +akhq: + security: + default-group: no-roles + rest: + enabled: true + url: https://external.service/get-roles-and-attributes + groups: # anything set here will not be used +```` + +In this mode, AKHQ will send to the ``akhq.security.rest.url`` endpoint a POST request with the following JSON : + +````json +{ + "providerType": "LDAP or OIDC or BASIC_AUTH", + "providerName": "OIDC provider name (OIDC only)", + "username": "user", + "groups": ["LDAP-GROUP-1", "LDAP-GROUP-2", "LDAP-GROUP-3"] +} +```` +and expect the following JSON as response : +````json +{ + "roles": ["topic/read", "topic/write", "..."], + "attributes": + { + "topics-filter-regexp": [".*"], + "connects-filter-regexp": [".*"], + "consumer-groups-filter-regexp": [".*"] + } +} +```` + +**Groovy API** +````yaml +akhq: + security: + default-group: no-roles + groovy: + enabled: true + file: | + package org.akhq.utils; + class GroovyCustomClaimProvider implements ClaimProvider { + @Override + AKHQClaimResponse generateClaim(AKHQClaimRequest request) { + AKHQClaimResponse a = new AKHQClaimResponse(); + a.roles = ["topic/read"] + a.attributes = [ + topicsFilterRegexp: [".*"], + connectsFilterRegexp: [".*"], + consumerGroupsFilterRegexp: [".*"] + ] + return a + } + } + groups: # anything set here will not be used +```` +``akhq.security.groovy.file`` must be a groovy class that implements the interface ClaimProvider : +````java +package org.akhq.utils; +public interface ClaimProvider { + + AKHQClaimResponse generateClaim(AKHQClaimRequest request); + + class AKHQClaimRequest{ + ProviderType providerType; + String providerName; + String username; + List groups; + } + class AKHQClaimResponse { + private List roles; + private Map attributes; + } + enum ProviderType { + BASIC_AUTH, + LDAP, + OIDC + } +} +```` + ### Debugging authentication Debugging auth can be done by increasing log level on Micronaut that handle most of the authentication part : diff --git a/build.gradle b/build.gradle index aafc7e99d..4e4533c02 100644 --- a/build.gradle +++ b/build.gradle @@ -111,6 +111,9 @@ dependencies { // Password hashing implementation group: "org.mindrot", name: "jbcrypt", version: "0.4" + // https://mvnrepository.com/artifact/org.codehaus.groovy/groovy-all + implementation group: 'org.codehaus.groovy', name: 'groovy-all', version: '3.0.7' + // api // client implementation project(":client") diff --git a/src/main/java/org/akhq/controllers/AbstractController.java b/src/main/java/org/akhq/controllers/AbstractController.java index fdca6d937..a9518d4fb 100644 --- a/src/main/java/org/akhq/controllers/AbstractController.java +++ b/src/main/java/org/akhq/controllers/AbstractController.java @@ -4,7 +4,7 @@ import io.micronaut.context.annotation.Value; import io.micronaut.security.utils.SecurityService; import org.akhq.configs.SecurityProperties; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.DefaultGroupUtils; import javax.inject.Inject; import java.net.URI; @@ -20,7 +20,7 @@ abstract public class AbstractController { private ApplicationContext applicationContext; @Inject - private UserGroupUtils userGroupUtils; + private DefaultGroupUtils defaultGroupUtils; @Inject private SecurityProperties securityProperties; @@ -63,7 +63,7 @@ protected boolean isAllowed(String role) { @SuppressWarnings("unchecked") protected List getRights() { if (!applicationContext.containsBean(SecurityService.class)) { - return expandRoles(this.userGroupUtils.getUserRoles(Collections.singletonList(securityProperties.getDefaultGroup()))); + return expandRoles(this.defaultGroupUtils.getDefaultRoles()); } SecurityService securityService = applicationContext.getBean(SecurityService.class); @@ -72,7 +72,7 @@ protected List getRights() { securityService .getAuthentication() .map(authentication -> (List) authentication.getAttributes().get("roles")) - .orElseGet(() -> this.userGroupUtils.getUserRoles(Collections.singletonList(securityProperties.getDefaultGroup()))) + .orElseGet(() -> this.defaultGroupUtils.getDefaultRoles()) ); } } diff --git a/src/main/java/org/akhq/modules/BasicAuthAuthenticationProvider.java b/src/main/java/org/akhq/modules/BasicAuthAuthenticationProvider.java index a3de6e6e1..af977b1c2 100644 --- a/src/main/java/org/akhq/modules/BasicAuthAuthenticationProvider.java +++ b/src/main/java/org/akhq/modules/BasicAuthAuthenticationProvider.java @@ -5,43 +5,53 @@ import io.micronaut.security.authentication.*; import io.reactivex.Flowable; import org.akhq.configs.BasicAuth; -import org.akhq.configs.Ldap; -import org.akhq.configs.Oidc; import org.akhq.configs.SecurityProperties; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.ClaimProvider; import org.reactivestreams.Publisher; import javax.inject.Inject; import javax.inject.Singleton; +import java.util.Optional; @Singleton public class BasicAuthAuthenticationProvider implements AuthenticationProvider { @Inject private SecurityProperties securityProperties; @Inject - private Oidc oidc; - @Inject - private Ldap ldap; - - @Inject - private UserGroupUtils userGroupUtils; + private ClaimProvider claimProvider; @Override public Publisher authenticate(@Nullable HttpRequest httpRequest, AuthenticationRequest authenticationRequest) { String username = String.valueOf(authenticationRequest.getIdentity()); - for (BasicAuth auth : securityProperties.getBasicAuth()) { - if (!username.equals(auth.getUsername())) { - continue; - } - if (!auth.isValidPassword((String) authenticationRequest.getSecret())) { - return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH)); - } - UserDetails userDetails = new UserDetails(username, - userGroupUtils.getUserRoles(auth.getGroups()), - userGroupUtils.getUserAttributes(auth.getGroups())); - return Flowable.just(userDetails); + Optional optionalBasicAuth = securityProperties.getBasicAuth() + .stream() + .filter(basicAuth -> basicAuth.getUsername().equals(username)) + .findFirst(); + + // User not found + if(optionalBasicAuth.isEmpty()){ + return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.USER_NOT_FOUND)); } + BasicAuth auth = optionalBasicAuth.get(); - return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.USER_NOT_FOUND)); + // Invalid password + if (!auth.isValidPassword((String) authenticationRequest.getSecret())) { + return Flowable.just(new AuthenticationFailed(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH)); + } + + ClaimProvider.AKHQClaimRequest request = + ClaimProvider.AKHQClaimRequest.builder() + .providerType(ClaimProvider.ProviderType.BASIC_AUTH) + .providerName(null) + .username(auth.getUsername()) + .groups(auth.getGroups()) + .build(); + try { + ClaimProvider.AKHQClaimResponse claim = claimProvider.generateClaim(request); + return Flowable.just(new UserDetails(auth.getUsername(), claim.getRoles(), claim.getAttributes())); + } catch (Exception e) { + String claimProviderClass = claimProvider.getClass().getName(); + return Flowable.just(new AuthenticationFailed("Exception from ClaimProvider " + claimProviderClass + ": " + e.getMessage())); + } } } diff --git a/src/main/java/org/akhq/modules/LdapContextAuthenticationMapper.java b/src/main/java/org/akhq/modules/LdapContextAuthenticationMapper.java index 1c0c56866..f7a9df962 100644 --- a/src/main/java/org/akhq/modules/LdapContextAuthenticationMapper.java +++ b/src/main/java/org/akhq/modules/LdapContextAuthenticationMapper.java @@ -4,10 +4,10 @@ import io.micronaut.configuration.security.ldap.DefaultContextAuthenticationMapper; import io.micronaut.context.annotation.Replaces; import io.micronaut.core.convert.value.ConvertibleValues; +import io.micronaut.security.authentication.AuthenticationFailed; import io.micronaut.security.authentication.AuthenticationResponse; import io.micronaut.security.authentication.UserDetails; -import org.akhq.configs.Ldap; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.ClaimProvider; import javax.inject.Inject; import javax.inject.Singleton; @@ -19,23 +19,24 @@ public class LdapContextAuthenticationMapper implements ContextAuthenticationMapper { @Inject - private Ldap ldap; - - @Inject - private UserGroupUtils userGroupUtils; + private ClaimProvider claimProvider; @Override public AuthenticationResponse map(ConvertibleValues attributes, String username, Set groups) { - List akhqGroups = getUserAkhqGroups(username, groups); - return new UserDetails(username, userGroupUtils.getUserRoles(akhqGroups), userGroupUtils.getUserAttributes(akhqGroups)); + ClaimProvider.AKHQClaimRequest request = ClaimProvider.AKHQClaimRequest.builder() + .providerType(ClaimProvider.ProviderType.LDAP) + .providerName(null) + .username(username) + .groups(List.copyOf(groups)) + .build(); + try { + ClaimProvider.AKHQClaimResponse claim = claimProvider.generateClaim(request); + return new UserDetails(username, claim.getRoles(), claim.getAttributes()); + } catch (Exception e) { + String claimProviderClass = claimProvider.getClass().getName(); + return new AuthenticationFailed("Exception from ClaimProvider " + claimProviderClass + ": " + e.getMessage()); + } } - /** - * Get Akhq Groups configured in Ldap groups - * @param ldapGroups list of ldap groups associated to the user - * @return list of Akhq groups configured for the ldap groups. See in application.yml property akhq.security.ldap - */ - private List getUserAkhqGroups(String username, Set ldapGroups) { - return UserGroupUtils.mapToAkhqGroups(username, ldapGroups, ldap.getGroups(), ldap.getUsers(), ldap.getDefaultGroup()); - } + } diff --git a/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java b/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java index f1df2eb86..1130c2486 100644 --- a/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java +++ b/src/main/java/org/akhq/modules/OidcUserDetailsMapper.java @@ -3,59 +3,75 @@ import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; import io.micronaut.core.util.StringUtils; +import io.micronaut.security.authentication.AuthenticationFailed; +import io.micronaut.security.authentication.AuthenticationResponse; import io.micronaut.security.authentication.UserDetails; import io.micronaut.security.config.AuthenticationModeConfiguration; import io.micronaut.security.oauth2.configuration.OpenIdAdditionalClaimsConfiguration; +import io.micronaut.security.oauth2.endpoint.authorization.state.State; import io.micronaut.security.oauth2.endpoint.token.response.DefaultOpenIdUserDetailsMapper; import io.micronaut.security.oauth2.endpoint.token.response.OpenIdClaims; import io.micronaut.security.oauth2.endpoint.token.response.OpenIdTokenResponse; import org.akhq.configs.Oidc; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.ClaimProvider; -import java.util.*; -import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Singleton; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; /** * An OpenID user details mapper that is configurable in the akhq config. - * + *

* It will read a username and roles from the OpenID claims and translate them to akhq roles. */ @Singleton @Replaces(DefaultOpenIdUserDetailsMapper.class) @Requires(property = "akhq.security.oidc.enabled", value = StringUtils.TRUE) public class OidcUserDetailsMapper extends DefaultOpenIdUserDetailsMapper { - private final OpenIdAdditionalClaimsConfiguration openIdAdditionalClaimsConfiguration; - private final UserGroupUtils userGroupUtils; - private final Oidc oidc; - - /** - * Default constructor. - * - * @param openIdAdditionalClaimsConfiguration The additional claims configuration - * @param oidc the OIDC configuration - * @param userGroupUtils the utils class to translate user groups - */ @Inject - public OidcUserDetailsMapper( - OpenIdAdditionalClaimsConfiguration openIdAdditionalClaimsConfiguration, - AuthenticationModeConfiguration authenticationModeConfiguration, - Oidc oidc, - UserGroupUtils userGroupUtils - ) { + private Oidc oidc; + @Inject + private ClaimProvider claimProvider; + + public OidcUserDetailsMapper(OpenIdAdditionalClaimsConfiguration openIdAdditionalClaimsConfiguration, AuthenticationModeConfiguration authenticationModeConfiguration) { super(openIdAdditionalClaimsConfiguration, authenticationModeConfiguration); - this.openIdAdditionalClaimsConfiguration = openIdAdditionalClaimsConfiguration; - this.oidc = oidc; - this.userGroupUtils = userGroupUtils; } + + @NonNull + @Override + public AuthenticationResponse createAuthenticationResponse(String providerName, OpenIdTokenResponse tokenResponse, OpenIdClaims openIdClaims, @Nullable State state) { + // get username and groups declared from OIDC system + String oidcUsername = getUsername(providerName, tokenResponse, openIdClaims); + List oidcGroups = getOidcGroups(oidc.getProvider(providerName), openIdClaims); + + ClaimProvider.AKHQClaimRequest request = ClaimProvider.AKHQClaimRequest.builder() + .providerType(ClaimProvider.ProviderType.OIDC) + .providerName(providerName) + .username(oidcUsername) + .groups(oidcGroups) + .build(); + + try { + ClaimProvider.AKHQClaimResponse claim = claimProvider.generateClaim(request); + return new UserDetails(oidcUsername, claim.getRoles(), claim.getAttributes()); + } catch (Exception e) { + String claimProviderClass = claimProvider.getClass().getName(); + return new AuthenticationFailed("Exception from ClaimProvider " + claimProviderClass + ": " + e.getMessage()); + } + } + /** * Tries to read the username from the configured username field. * - * @param providerName The OpenID provider name + * @param providerName The OpenID provider name * @param tokenResponse The token response - * @param openIdClaims The OpenID claims + * @param openIdClaims The OpenID claims * @return The username to set in the {@link UserDetails} */ protected String getUsername(String providerName, OpenIdTokenResponse tokenResponse, OpenIdClaims openIdClaims) { @@ -63,83 +79,25 @@ protected String getUsername(String providerName, OpenIdTokenResponse tokenRespo return Objects.toString(openIdClaims.get(provider.getUsernameField())); } - /** - * Tries to read groups from the configured groups field and translates them using {@link UserGroupUtils}. - * - * @param providerName The OpenID provider name - * @param openIdClaims The OpenID claims - * @param username The username used for mapping - * @return The AKHQ internal groups - */ - protected List getAkhqGroups(String providerName, OpenIdClaims openIdClaims, String username) { - Oidc.Provider provider = oidc.getProvider(providerName); - Set providerGroups = getOidcGroups(provider, openIdClaims); - return UserGroupUtils.mapToAkhqGroups(username, providerGroups, provider.getGroups(), provider.getUsers(), provider.getDefaultGroup()); - } - /** - * Adds the configured attributes for the user roles to the user attributes. - * - * @param providerName The OpenID provider name - * @param tokenResponse The token response - * @param openIdClaims The OpenID claims - * @return The attributes to set in the {@link UserDetails} - */ - protected Map buildAttributes( - String providerName, - OpenIdTokenResponse tokenResponse, - OpenIdClaims openIdClaims, - List akhqGroups - ) { - Map attributes = super.buildAttributes(providerName, tokenResponse, openIdClaims); - userGroupUtils.getUserAttributes(akhqGroups).forEach(attributes::put); - return attributes; - } - /** * Tries to read groups from the configured groups field. * If the configured field cannot be found or isn't some kind of collection, it will return an empty set. * - * @param provider The OpenID provider configuration + * @param provider The OpenID provider configuration * @param openIdClaims The OpenID claims * @return The groups from oidc */ - protected Set getOidcGroups(Oidc.Provider provider, OpenIdClaims openIdClaims) { - Set groups = new HashSet<>(); + protected List getOidcGroups(Oidc.Provider provider, OpenIdClaims openIdClaims) { + List groups = new ArrayList<>(); if (openIdClaims.contains(provider.getGroupsField())) { Object groupsField = openIdClaims.get(provider.getGroupsField()); if (groupsField instanceof Collection) { groups = ((Collection) groupsField) .stream() .map(Objects::toString) - .collect(Collectors.toSet()); + .collect(Collectors.toList()); } } return groups; } - - /** - * @param providerName The OpenID provider name - * @param tokenResponse The token response - * @param openIdClaims The OpenID claims - * @return A user details object - */ - @NonNull - @Override - public UserDetails createUserDetails(String providerName, OpenIdTokenResponse tokenResponse, OpenIdClaims openIdClaims) { - String username = getUsername(providerName, tokenResponse, openIdClaims); - List akhqGroups = getAkhqGroups(providerName, openIdClaims, username); - List roles = userGroupUtils.getUserRoles(akhqGroups); - Map attributes = buildAttributes(providerName, tokenResponse, openIdClaims, akhqGroups); - - /** - * In case of OIDC the user roles are not correctly mapped to corresponding roles in akhq, - * If we find a groups-field in the user attributes override it with the correctly mapped - * roles that match the associated akhq group - */ - Oidc.Provider provider = oidc.getProvider(providerName); - if (attributes.containsKey(provider.getGroupsField())) { - attributes.put(provider.getGroupsField(), roles); - } - return new UserDetails(username, roles, attributes); - } } diff --git a/src/main/java/org/akhq/modules/SecuredAnnotationRuleWithDefault.java b/src/main/java/org/akhq/modules/SecuredAnnotationRuleWithDefault.java index 693257219..fe14ebaea 100644 --- a/src/main/java/org/akhq/modules/SecuredAnnotationRuleWithDefault.java +++ b/src/main/java/org/akhq/modules/SecuredAnnotationRuleWithDefault.java @@ -10,7 +10,7 @@ import io.micronaut.web.router.MethodBasedRouteMatch; import io.micronaut.web.router.RouteMatch; import org.akhq.configs.SecurityProperties; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.DefaultGroupUtils; import javax.inject.Inject; import javax.inject.Singleton; @@ -30,13 +30,13 @@ public class SecuredAnnotationRuleWithDefault extends SecuredAnnotationRule { private SecurityProperties securityProperties; @Inject - private UserGroupUtils userGroupUtils; + private DefaultGroupUtils defaultGroupUtils; @Override protected List getRoles(Map claims) { List roles = super.getRoles(claims); - roles.addAll(this.userGroupUtils.getUserRoles(Collections.singletonList(securityProperties.getDefaultGroup()))); + roles.addAll(this.defaultGroupUtils.getDefaultRoles()); return roles; } diff --git a/src/main/java/org/akhq/repositories/ConnectRepository.java b/src/main/java/org/akhq/repositories/ConnectRepository.java index 2fd5fd7b1..a0cbbc813 100644 --- a/src/main/java/org/akhq/repositories/ConnectRepository.java +++ b/src/main/java/org/akhq/repositories/ConnectRepository.java @@ -16,7 +16,7 @@ import org.akhq.modules.KafkaModule; import org.akhq.utils.PagedList; import org.akhq.utils.Pagination; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.DefaultGroupUtils; import org.sourcelab.kafka.connect.apiclient.request.dto.*; import org.sourcelab.kafka.connect.apiclient.rest.exceptions.ConcurrentConfigModificationException; import org.sourcelab.kafka.connect.apiclient.rest.exceptions.InvalidRequestException; @@ -28,7 +28,6 @@ import java.util.*; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; -import java.util.regex.Pattern; @Singleton public class ConnectRepository extends AbstractRepository { @@ -39,7 +38,7 @@ public class ConnectRepository extends AbstractRepository { private ApplicationContext applicationContext; @Inject - private UserGroupUtils userGroupUtils; + private DefaultGroupUtils defaultGroupUtils; @Inject private SecurityProperties securityProperties; @@ -268,7 +267,7 @@ private Optional> getConnectFilterRegex() { } // get Connect filter regex for default groups connectFilterRegex.addAll(getConnectFilterRegexFromAttributes( - userGroupUtils.getUserAttributes(Collections.singletonList(securityProperties.getDefaultGroup())) + defaultGroupUtils.getDefaultAttributes() )); return Optional.of(connectFilterRegex); diff --git a/src/main/java/org/akhq/repositories/ConsumerGroupRepository.java b/src/main/java/org/akhq/repositories/ConsumerGroupRepository.java index d2eb3dd32..0e573183c 100644 --- a/src/main/java/org/akhq/repositories/ConsumerGroupRepository.java +++ b/src/main/java/org/akhq/repositories/ConsumerGroupRepository.java @@ -10,7 +10,7 @@ import org.akhq.modules.KafkaModule; import org.akhq.utils.PagedList; import org.akhq.utils.Pagination; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.DefaultGroupUtils; import org.apache.kafka.clients.admin.ConsumerGroupDescription; import org.apache.kafka.clients.admin.ConsumerGroupListing; import org.apache.kafka.clients.consumer.ConsumerConfig; @@ -37,7 +37,7 @@ public class ConsumerGroupRepository extends AbstractRepository { private ApplicationContext applicationContext; @Inject - private UserGroupUtils userGroupUtils; + private DefaultGroupUtils defaultGroupUtils; @Inject private SecurityProperties securityProperties; @@ -175,7 +175,7 @@ private Optional> getConsumerGroupFilterRegex() { } // get consumer group filter regex for default groups consumerGroupFilterRegex.addAll(getConsumerGroupFilterRegexFromAttributes( - userGroupUtils.getUserAttributes(Collections.singletonList(securityProperties.getDefaultGroup())) + defaultGroupUtils.getDefaultAttributes() )); return Optional.of(consumerGroupFilterRegex); diff --git a/src/main/java/org/akhq/repositories/TopicRepository.java b/src/main/java/org/akhq/repositories/TopicRepository.java index ffe45856f..3bbb672f9 100644 --- a/src/main/java/org/akhq/repositories/TopicRepository.java +++ b/src/main/java/org/akhq/repositories/TopicRepository.java @@ -13,7 +13,7 @@ import org.akhq.modules.AbstractKafkaWrapper; import org.akhq.utils.PagedList; import org.akhq.utils.Pagination; -import org.akhq.utils.UserGroupUtils; +import org.akhq.utils.DefaultGroupUtils; import javax.inject.Inject; import javax.inject.Singleton; @@ -38,7 +38,7 @@ public class TopicRepository extends AbstractRepository { private ApplicationContext applicationContext; @Inject - private UserGroupUtils userGroupUtils; + private DefaultGroupUtils defaultGroupUtils; @Value("${akhq.topic.internal-regexps}") protected List internalRegexps; @@ -172,7 +172,7 @@ private Optional> getTopicFilterRegex() { } // get topic filter regex for default groups topicFilterRegex.addAll(getTopicFilterRegexFromAttributes( - userGroupUtils.getUserAttributes(Collections.singletonList(securityProperties.getDefaultGroup())) + defaultGroupUtils.getDefaultAttributes() )); return Optional.of(topicFilterRegex); diff --git a/src/main/java/org/akhq/utils/ClaimProvider.java b/src/main/java/org/akhq/utils/ClaimProvider.java new file mode 100644 index 000000000..d63897fca --- /dev/null +++ b/src/main/java/org/akhq/utils/ClaimProvider.java @@ -0,0 +1,42 @@ +package org.akhq.utils; + +import io.micronaut.core.annotation.Introspected; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; +import java.util.Map; + +public interface ClaimProvider { + + AKHQClaimResponse generateClaim(AKHQClaimRequest request); + + enum ProviderType { + BASIC_AUTH, + LDAP, + OIDC + } + + @Introspected + @Builder + @Getter + @Setter + class AKHQClaimResponse { + private List roles; + private Map attributes; + } + + @Introspected + @Builder + @Getter + @Setter + class AKHQClaimRequest{ + ProviderType providerType; + String providerName; + String username; + List groups; + } + +} diff --git a/src/main/java/org/akhq/utils/DefaultGroupUtils.java b/src/main/java/org/akhq/utils/DefaultGroupUtils.java new file mode 100644 index 000000000..8c7a6576f --- /dev/null +++ b/src/main/java/org/akhq/utils/DefaultGroupUtils.java @@ -0,0 +1,60 @@ +package org.akhq.utils; + +import io.micronaut.core.util.StringUtils; +import org.akhq.configs.GroupMapping; +import org.akhq.configs.SecurityProperties; +import org.akhq.configs.UserMapping; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Singleton +public class DefaultGroupUtils { + + @Inject + private SecurityProperties securityProperties; + + /** + * Get all default roles for all users (authenticated and not) + * + * @return list of roles + */ + public List getDefaultRoles() { + if (securityProperties.getGroups() == null || StringUtils.isEmpty(securityProperties.getDefaultGroup())) { + return null; + } + + return securityProperties.getGroups().values().stream() + .filter(group -> securityProperties.getDefaultGroup().equals(group.getName())) + .filter(group -> group.getRoles() != null) + .flatMap(group -> group.getRoles().stream()) + .distinct() + .collect(Collectors.toList()); + } + + /** + * Get all default attributes for all users (authenticated and not) + * + * @return Map> + */ + @SuppressWarnings("unchecked") + public Map getDefaultAttributes() { + if (securityProperties.getGroups() == null || StringUtils.isEmpty(securityProperties.getDefaultGroup())) { + return null; + } + + return securityProperties.getGroups().values().stream() + .filter(group -> securityProperties.getDefaultGroup().equals(group.getName())) + .flatMap(group -> (group.getAttributes() != null) ? group.getAttributes().entrySet().stream() : null) + .collect(Collectors.toMap( + Map.Entry::getKey, + item -> new ArrayList<>(item.getValue()), + (e1, e2) -> { + ((List) e1).addAll((List) e2); return e1; + } + )); + } +} diff --git a/src/main/java/org/akhq/utils/GroovyClaimProvider.java b/src/main/java/org/akhq/utils/GroovyClaimProvider.java new file mode 100644 index 000000000..3df09ad2c --- /dev/null +++ b/src/main/java/org/akhq/utils/GroovyClaimProvider.java @@ -0,0 +1,44 @@ +package org.akhq.utils; + +import groovy.lang.GroovyClassLoader; +import io.micronaut.context.annotation.Primary; +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.annotation.Value; +import io.micronaut.core.util.StringUtils; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.PostConstruct; +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; + +@Slf4j +@Singleton +@Primary +@Requires(property = "akhq.security.groovy.enabled", value = StringUtils.TRUE) +public class GroovyClaimProvider implements ClaimProvider { + final GroovyClassLoader loader = new GroovyClassLoader(); + private ClaimProvider groovyImpl; + + @Value("${akhq.security.groovy.file}") + private String groovyFile; + + @PostConstruct + private void init() { + try { + // the file must be an implementation of ClaimProvider Interface + final Class clazz = loader.parseClass(groovyFile); + groovyImpl = ClaimProvider.class.cast(clazz.getDeclaredConstructors()[0].newInstance()); + + } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) { + log.error("Error", e); + } + } + + @Override + public AKHQClaimResponse generateClaim(AKHQClaimRequest request) { + return groovyImpl.generateClaim(request); + } +} diff --git a/src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java b/src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java new file mode 100644 index 000000000..f8342dc15 --- /dev/null +++ b/src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java @@ -0,0 +1,141 @@ +package org.akhq.utils; + +import io.micronaut.context.annotation.Secondary; +import io.micronaut.core.util.StringUtils; +import org.akhq.configs.*; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Singleton +@Secondary +public class LocalSecurityClaimProvider implements ClaimProvider { + + @Inject + SecurityProperties securityProperties; + @Inject + Ldap ldapProperties; + @Inject + Oidc oidcProperties; + + @Override + public AKHQClaimResponse generateClaim(AKHQClaimRequest request) { + List userMappings; + List groupMappings; + String defaultGroup; + List akhqGroups = new ArrayList<>(); + switch (request.getProviderType()) { + case BASIC_AUTH: + // we already have target AKHQ groups + akhqGroups.addAll(request.getGroups()); + break; + case LDAP: + // we need to convert from LDAP groups to AKHQ groups to find the roles and attributes + // using akhq.security.ldap.groups and akhq.security.ldap.users + // as well as akhq.security.ldap.default-group + userMappings = ldapProperties.getUsers(); + groupMappings = ldapProperties.getGroups(); + defaultGroup = ldapProperties.getDefaultGroup(); + akhqGroups.addAll(mapToAkhqGroups(request.getUsername(), request.getGroups(), groupMappings, userMappings, defaultGroup)); + break; + case OIDC: + // we need to convert from OIDC groups to AKHQ groups to find the roles and attributes + // using akhq.security.oidc.groups and akhq.security.oidc.users + // as well as akhq.security.oidc.default-group + Oidc.Provider provider = oidcProperties.getProvider(request.getProviderName()); + userMappings = provider.getUsers(); + groupMappings = provider.getGroups(); + defaultGroup = provider.getDefaultGroup(); + akhqGroups.addAll(mapToAkhqGroups(request.getUsername(), request.getGroups(), groupMappings, userMappings, defaultGroup)); + break; + default: + break; + } + + // translate akhq groups into roles and attributes + return generateClaimFromAKHQGroups(request.getUsername(), akhqGroups); + } + + /** + * Maps the provider username and a set of provider groups to AKHQ groups using group and user mappings. + * + * @param username the username to use + * @param providerGroups the groups from the provider side + * @param groupMappings the group mappings configured for the provider + * @param userMappings the user mappings configured for the provider + * @param defaultGroup a default group for the provider + * @return the mapped AKHQ groups + */ + public List mapToAkhqGroups( + String username, + List providerGroups, + List groupMappings, + List userMappings, + String defaultGroup) { + Stream defaultGroupStream = StringUtils.hasText(defaultGroup) ? Stream.of(defaultGroup) : Stream.empty(); + return Stream.concat( + Stream.concat( + userMappings.stream() + .filter(mapping -> username.equalsIgnoreCase(mapping.getUsername())) + .flatMap(mapping -> mapping.getGroups().stream()), + groupMappings.stream() + .filter(mapping -> providerGroups.stream().anyMatch(s -> s.equalsIgnoreCase(mapping.getName()))) + .flatMap(mapping -> mapping.getGroups().stream()) + ), + defaultGroupStream + ).distinct().collect(Collectors.toList()); + } + + public AKHQClaimResponse generateClaimFromAKHQGroups(String username, List groups) { + return AKHQClaimResponse.builder() + .roles(getUserRoles(groups)) + .attributes( + Map.of( + "topicsFilterRegexp", getAttributeMergedList(groups, "topicsFilterRegexp"), + "connectsFilterRegexp", getAttributeMergedList(groups, "connectsFilterRegexp"), + "consumerGroupsFilterRegexp", getAttributeMergedList(groups, "consumerGroupsFilterRegexp") + ) + ) + .build(); + } + + /** + * Get all distinct roles for the list of groups + * + * @param groups list of user groups + * @return list of roles + */ + public List getUserRoles(List groups) { + if (securityProperties.getGroups() == null || groups == null) { + return List.of(); + } + + return securityProperties.getGroups().values().stream() + .filter(group -> groups.contains(group.getName())) + .filter(group -> group.getRoles() != null) + .flatMap(group -> group.getRoles().stream()) + .distinct() + .collect(Collectors.toList()); + } + + public List getAttributeMergedList(List groups, String attribute) { + return securityProperties.getGroups().values().stream() + //group matches + .filter(group -> groups.contains(group.getName())) + //group contains this attribute in the attributes Map + .filter(group -> group.getAttributes() != null && group.getAttributes().containsKey(attribute)) + // attribute is not an empty List + .filter(group -> group.getAttributes().get(attribute) != null && !group.getAttributes().get(attribute).isEmpty()) + //flatMap attribute List + .flatMap(group -> group.getAttributes().get(attribute).stream()) + //dedup & collect + .distinct() + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/org/akhq/utils/RestApiClaimProvider.java b/src/main/java/org/akhq/utils/RestApiClaimProvider.java new file mode 100644 index 000000000..5358ded70 --- /dev/null +++ b/src/main/java/org/akhq/utils/RestApiClaimProvider.java @@ -0,0 +1,18 @@ +package org.akhq.utils; + +import io.micronaut.context.annotation.Primary; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.client.annotation.Client; + +@Primary +@Requires(property = "akhq.security.rest.enabled", value = StringUtils.TRUE) +@Client("${akhq.security.rest.url}") +public interface RestApiClaimProvider extends ClaimProvider { + + @Post + @Override + AKHQClaimResponse generateClaim(@Body AKHQClaimRequest request); +} diff --git a/src/main/java/org/akhq/utils/UserGroupUtils.java b/src/main/java/org/akhq/utils/UserGroupUtils.java deleted file mode 100644 index f34d1aac8..000000000 --- a/src/main/java/org/akhq/utils/UserGroupUtils.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.akhq.utils; - -import io.micronaut.core.util.StringUtils; -import org.akhq.configs.GroupMapping; -import org.akhq.configs.SecurityProperties; -import org.akhq.configs.UserMapping; - -import javax.inject.Inject; -import javax.inject.Singleton; -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -@Singleton -public class UserGroupUtils { - - @Inject - private SecurityProperties securityProperties; - - /** - * Get all distinct roles for the list of groups - * - * @param groups list of user groups - * @return list of roles - */ - public List getUserRoles(List groups) { - if (securityProperties.getGroups() == null || groups == null) { - return new ArrayList<>(); - } - - return securityProperties.getGroups().values().stream() - .filter(group -> groups.contains(group.getName())) - .filter(group -> group.getRoles() != null) - .flatMap(group -> group.getRoles().stream()) - .distinct() - .collect(Collectors.toList()); - } - - /** - * Merge all group attributes in a Map - * - * @param groups list of user groups - * @return Map> - */ - @SuppressWarnings("unchecked") - public Map getUserAttributes(List groups) { - if (securityProperties.getGroups() == null || groups == null) { - return null; - } - - return securityProperties.getGroups().values().stream() - .filter(group -> groups.contains(group.getName())) - .flatMap(group -> (group.getAttributes() != null) ? group.getAttributes().entrySet().stream() : null) - .collect(Collectors.toMap( - Map.Entry::getKey, - item -> new ArrayList<>(item.getValue()), - (e1, e2) -> { - ((List) e1).addAll((List) e2); return e1; - } - )); - } - - /** - * Maps the provider username and a set of provider groups to AKHQ groups using group and user mappings. - * - * @param username the username to use - * @param providerGroups the groups from the provider side - * @param groupMappings the group mappings configured for the provider - * @param userMappings the user mappings configured for the provider - * @param defaultGroup a default group for the provider - * @return the mapped AKHQ groups - */ - public static List mapToAkhqGroups( - String username, - Set providerGroups, - List groupMappings, - List userMappings, - String defaultGroup - ) { - Stream defaultGroupStream = StringUtils.hasText(defaultGroup) ? Stream.of(defaultGroup) : Stream.empty(); - return Stream.concat( - Stream.concat( - userMappings.stream() - .filter(mapping -> username.equalsIgnoreCase(mapping.getUsername())) - .flatMap(mapping -> mapping.getGroups().stream()), - groupMappings.stream() - .filter(mapping -> providerGroups.stream().anyMatch(s -> s.equalsIgnoreCase(mapping.getName()))) - .flatMap(mapping -> mapping.getGroups().stream()) - ), - defaultGroupStream - ).distinct().collect(Collectors.toList()); - } -} diff --git a/src/test/java/org/akhq/configs/UserGroupUtilsTest.java b/src/test/java/org/akhq/configs/UserGroupUtilsTest.java deleted file mode 100644 index 58dfa0f4d..000000000 --- a/src/test/java/org/akhq/configs/UserGroupUtilsTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.akhq.configs; - -import io.micronaut.context.ApplicationContext; -import org.akhq.utils.UserGroupUtils; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; - -public class UserGroupUtilsTest { - @Test - void testTopicRegexpAsString() { - ApplicationContext ctx = ApplicationContext.run(ApplicationContext.class,"filterregex"); - - UserGroupUtils userGroupUtils = ctx.getBean(UserGroupUtils.class); - - List actual = (List)userGroupUtils.getUserAttributes(List.of("as-string")).get("topicsFilterRegexp"); - - assertEquals( - 1, - actual.size() - ); - assertIterableEquals( - List.of("test.*"), - actual - ); - - ctx.close(); - } - - @Test - void testTopicRegexpAsList() { - - ApplicationContext ctx = ApplicationContext.run(ApplicationContext.class,"filterregex"); - - UserGroupUtils userGroupUtils = ctx.getBean(UserGroupUtils.class); - - List actual = (List)userGroupUtils.getUserAttributes(List.of("as-list")).get("topicsFilterRegexp"); - - assertEquals( - 2, - actual.size() - ); - assertIterableEquals( - List.of("item1","item2"), - actual - ); - - ctx.close(); - } - @Test - void testTopicRegexpAsMixed() { - - ApplicationContext ctx = ApplicationContext.run(ApplicationContext.class,"filterregex"); - - UserGroupUtils userGroupUtils = ctx.getBean(UserGroupUtils.class); - - List actual = (List)userGroupUtils.getUserAttributes(List.of("as-string","as-list")).get("topicsFilterRegexp"); - - assertEquals( - 3, - actual.size() - ); - assertIterableEquals( - List.of("test.*","item1","item2"), - actual - ); - - ctx.close(); - } -} diff --git a/src/test/java/org/akhq/modules/BasicAuthAuthenticationProviderTest.java b/src/test/java/org/akhq/modules/BasicAuthAuthenticationProviderTest.java index 5fcf6e9d8..32433e322 100644 --- a/src/test/java/org/akhq/modules/BasicAuthAuthenticationProviderTest.java +++ b/src/test/java/org/akhq/modules/BasicAuthAuthenticationProviderTest.java @@ -1,8 +1,6 @@ package org.akhq.modules; -import io.micronaut.security.authentication.AuthenticationResponse; -import io.micronaut.security.authentication.UserDetails; -import io.micronaut.security.authentication.UsernamePasswordCredentials; +import io.micronaut.security.authentication.*; import io.reactivex.Flowable; import org.akhq.AbstractTest; import org.junit.jupiter.api.Test; @@ -44,7 +42,7 @@ public void success() { } @Test - public void failed() { + public void failed_UserNotFound() { AuthenticationResponse response = Flowable .fromPublisher(auth.authenticate(null, new UsernamePasswordCredentials( "user2", @@ -52,5 +50,19 @@ public void failed() { ))).blockingFirst(); assertFalse(response.isAuthenticated()); + AuthenticationFailed authenticationFailed = (AuthenticationFailed) response; + assertEquals(AuthenticationFailureReason.USER_NOT_FOUND, authenticationFailed.getReason()); + } + @Test + public void failed_PasswordInvalid() { + AuthenticationResponse response = Flowable + .fromPublisher(auth.authenticate(null, new UsernamePasswordCredentials( + "user", + "invalid-pass" + ))).blockingFirst(); + + assertFalse(response.isAuthenticated()); + AuthenticationFailed authenticationFailed = (AuthenticationFailed) response; + assertEquals(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH, authenticationFailed.getReason()); } } diff --git a/src/test/java/org/akhq/modules/GroovyClaimProviderTest.java b/src/test/java/org/akhq/modules/GroovyClaimProviderTest.java new file mode 100644 index 000000000..228fe31db --- /dev/null +++ b/src/test/java/org/akhq/modules/GroovyClaimProviderTest.java @@ -0,0 +1,47 @@ +package org.akhq.modules; + +import io.micronaut.security.authentication.AuthenticationResponse; +import io.micronaut.security.authentication.UserDetails; +import io.micronaut.security.authentication.UsernamePasswordCredentials; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.reactivex.Flowable; +import org.junit.jupiter.api.Test; + +import javax.inject.Inject; + +import java.util.Collection; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(environments = "groovy") +public class GroovyClaimProviderTest { + @Inject + BasicAuthAuthenticationProvider auth; + + @Test + public void successUser() { + AuthenticationResponse response = Flowable + .fromPublisher(auth.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(UserDetails.class)); + + UserDetails userDetail = (UserDetails) response; + + assertTrue(userDetail.isAuthenticated()); + assertEquals("user", userDetail.getUsername()); + + Collection roles = userDetail.getRoles(); + + assertThat(roles, hasSize(1)); + assertThat(roles, hasItem("topic/read")); + + assertEquals("single-topic", ((List)userDetail.getAttributes("roles", "username").get("topicsFilterRegexp")).get(0)); + } +} diff --git a/src/test/java/org/akhq/modules/OidcAuthenticationProviderTest.java b/src/test/java/org/akhq/modules/OidcAuthenticationProviderTest.java new file mode 100644 index 000000000..dd472b416 --- /dev/null +++ b/src/test/java/org/akhq/modules/OidcAuthenticationProviderTest.java @@ -0,0 +1,233 @@ +package org.akhq.modules; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import io.micronaut.core.async.publisher.Publishers; +import io.micronaut.security.authentication.*; +import io.micronaut.security.oauth2.client.DefaultOpenIdProviderMetadata; +import io.micronaut.security.oauth2.endpoint.token.request.TokenEndpointClient; +import io.micronaut.security.oauth2.endpoint.token.response.OpenIdClaims; +import io.micronaut.security.oauth2.endpoint.token.response.OpenIdTokenResponse; +import io.micronaut.security.oauth2.endpoint.token.response.validation.OpenIdTokenResponseValidator; +import io.micronaut.test.annotation.MockBean; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.reactivex.Flowable; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +import javax.inject.Inject; +import javax.inject.Named; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +@Slf4j +@MicronautTest(environments = "oidc") +public class OidcAuthenticationProviderTest { + + @Named("oidc") + @Inject + AuthenticationProvider oidcProvider; + + @Inject + TokenEndpointClient tokenEndpointClient; + + @Inject + OpenIdTokenResponseValidator openIdTokenResponseValidator; + + @Inject + DefaultOpenIdProviderMetadata defaultOpenIdProviderMetadata; + + @Named("oidc") + @MockBean(TokenEndpointClient.class) + TokenEndpointClient tokenEndpointClient() { + return mock(TokenEndpointClient.class); + } + + @Named("oidc") + @MockBean(OpenIdTokenResponseValidator.class) + OpenIdTokenResponseValidator openIdTokenResponseValidator() { + return mock(OpenIdTokenResponseValidator.class); + } + + @Named("oidc") + @MockBean(DefaultOpenIdProviderMetadata.class) + DefaultOpenIdProviderMetadata defaultOpenIdProviderMetadata() { + return mock(DefaultOpenIdProviderMetadata.class); + } + + @Test + void successSingleOidcGroup() { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user") + .claim("roles", List.of("oidc-limited-group")) + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(UserDetails.class)); + + UserDetails userDetail = (UserDetails) response; + + assertTrue(userDetail.isAuthenticated()); + assertEquals("user", userDetail.getUsername()); + + Collection roles = userDetail.getRoles(); + + assertThat(roles, hasSize(4)); + assertThat(roles, hasItem("topic/read")); + assertThat(roles, hasItem("registry/version/delete")); + + assertEquals("test.*", ((List) userDetail.getAttributes("roles", "username").get("topicsFilterRegexp")).get(0)); + + } + + @Test + public void successWithMultipleOidcGroups() { + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user") + .claim("roles", List.of("oidc-limited-group", "oidc-operator-group")) + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(UserDetails.class)); + + UserDetails userDetail = (UserDetails) response; + + assertTrue(userDetail.isAuthenticated()); + assertEquals("user", userDetail.getUsername()); + + Collection roles = userDetail.getRoles(); + + assertThat(roles, hasSize(7)); + assertThat(roles, hasItem("topic/read")); + assertThat(roles, hasItem("registry/version/delete")); + assertThat(roles, hasItem("topic/data/read")); + + List topicsFilterList = (List) (userDetail.getAttributes("roles", "username").get("topicsFilterRegexp")); + assertThat(topicsFilterList, hasSize(2)); + assertThat(topicsFilterList, hasItem("test.*")); + assertThat(topicsFilterList, hasItem("test-operator.*")); + } + + @Test + public void successWithOidcGroupAndUserRole() { + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user2") + .claim("roles", List.of("oidc-limited-group")) + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user2", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(UserDetails.class)); + + UserDetails userDetail = (UserDetails) response; + + assertTrue(userDetail.isAuthenticated()); + assertEquals("user2", userDetail.getUsername()); + + Collection roles = userDetail.getRoles(); + + assertThat(roles, hasSize(7)); + assertThat(roles, hasItem("topic/read")); + assertThat(roles, hasItem("registry/version/delete")); + assertThat(roles, hasItem("topic/data/read")); + + List topicsFilterList = (List) (userDetail.getAttributes("roles", "username").get("topicsFilterRegexp")); + assertThat(topicsFilterList, hasSize(2)); + assertThat(topicsFilterList, hasItem("test.*")); + assertThat(topicsFilterList, hasItem("test-operator.*")); + } + + @Test + public void successWithoutRoles() { + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, "user") + .claim("roles", List.of("oidc-other-group")) + .build(); + JWT jwt = new PlainJWT(claimsSet); + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.of(jwt)); + + AuthenticationResponse response = Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + + assertThat(response, instanceOf(UserDetails.class)); + + UserDetails userDetail = (UserDetails) response; + + assertTrue(userDetail.isAuthenticated()); + assertEquals("user", userDetail.getUsername()); + + Collection roles = userDetail.getRoles(); + assertThat(roles, hasSize(0)); + } + + @Test + public void failure() { + + Mockito.when(tokenEndpointClient.sendRequest(ArgumentMatchers.any())) + .thenReturn(Publishers.just(new OpenIdTokenResponse())); + Mockito.when(openIdTokenResponseValidator.validate(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(Optional.empty()); + + AuthenticationException authenticationException = assertThrows(AuthenticationException.class, () -> { + Flowable + .fromPublisher(oidcProvider.authenticate(null, new UsernamePasswordCredentials( + "user", + "pass" + ))).blockingFirst(); + }); + + assertThat(authenticationException.getResponse(), instanceOf(AuthenticationFailed.class)); + assertFalse(authenticationException.getResponse().isAuthenticated()); + } +} diff --git a/src/test/java/org/akhq/modules/OidcUserDetailsMapperTest.java b/src/test/java/org/akhq/modules/OidcUserDetailsMapperTest.java deleted file mode 100644 index 3ea1f73fa..000000000 --- a/src/test/java/org/akhq/modules/OidcUserDetailsMapperTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package org.akhq.modules; - -import com.nimbusds.jwt.JWTClaimsSet; -import io.micronaut.security.authentication.AuthenticationMode; -import io.micronaut.security.authentication.UserDetails; -import io.micronaut.security.config.AuthenticationModeConfiguration; -import io.micronaut.security.oauth2.configuration.OpenIdAdditionalClaimsConfiguration; -import io.micronaut.security.oauth2.endpoint.token.response.JWTOpenIdClaims; -import io.micronaut.security.oauth2.endpoint.token.response.OpenIdClaims; -import io.micronaut.security.oauth2.endpoint.token.response.OpenIdTokenResponse; -import org.akhq.configs.GroupMapping; -import org.akhq.configs.Oidc; -import org.akhq.configs.UserMapping; -import org.akhq.utils.UserGroupUtils; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.stubbing.Answer; - -import java.util.*; -import java.util.stream.Collectors; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class OidcUserDetailsMapperTest { - private static final String CLAIM_ROLES = "roles"; - - private static final String USERNAME = "example"; - private static final String GROUP_1 = "role1"; - private static final String GROUP_2 = "role2"; - private static final String USER_GROUP = "user-group"; - private static final String DEFAULT_GROUP = "test"; - private static final String INTERNAL_PREFIX = "internal/"; - private static final String TRANSLATED_PREFIX = "translated/"; - private static final String PROVIDER_NAME = "sample"; - - @Mock - private Oidc oidc; - @Mock - private UserGroupUtils userGroupUtils; - @Mock - private OpenIdAdditionalClaimsConfiguration openIdAdditionalClaimsConfiguration; - @Mock - private AuthenticationModeConfiguration authenticationModeConfiguration; - - @InjectMocks - private OidcUserDetailsMapper subject; - - @BeforeEach - void setUp() { - when(openIdAdditionalClaimsConfiguration.isAccessToken()).thenReturn(false); - when(openIdAdditionalClaimsConfiguration.isJwt()).thenReturn(false); - when(openIdAdditionalClaimsConfiguration.isRefreshToken()).thenReturn(false); - when(authenticationModeConfiguration.getAuthentication()).thenReturn(AuthenticationMode.COOKIE); - - when(userGroupUtils.getUserRoles(anyList())).thenAnswer((Answer>) invocation -> { - List input = (List) invocation.getArgument(0, List.class); - return input.stream().map(g -> TRANSLATED_PREFIX + g).collect(Collectors.toList()); - }); - when(userGroupUtils.getUserAttributes(anyList())).thenAnswer((Answer>) invocation -> { - List input = (List) invocation.getArgument(0, List.class); - final Map attributes = new HashMap<>(); - input.forEach(g -> attributes.put(g, true)); - return attributes; - }); - - Oidc.Provider provider = new Oidc.Provider(); - provider.setGroups( - Arrays.asList( - buildGroupMapping(GROUP_1, INTERNAL_PREFIX + GROUP_1), - buildGroupMapping(GROUP_2, INTERNAL_PREFIX + GROUP_2) - ) - ); - provider.setUsers(Collections.singletonList(buildUserMapping(USERNAME, "user-group"))); - provider.setDefaultGroup(DEFAULT_GROUP); - when(oidc.getProvider(PROVIDER_NAME)).thenReturn(provider); - } - - @Test - void createUserDetails() { - JWTOpenIdClaims claims = buildClaims(Arrays.asList(GROUP_1, GROUP_2)); - UserDetails userDetails = subject.createUserDetails(PROVIDER_NAME, new OpenIdTokenResponse(), claims); - assertEquals(USERNAME, userDetails.getUsername()); - assertContainsInAnyOrder( - Arrays.asList( - "translated/user-group", - "translated/internal/role1", - "translated/internal/role2", - "translated/test" - ), - userDetails.getRoles() - ); - Map attributes = userDetails.getAttributes("roles", "username"); - assertEquals(true, attributes.get("internal/role1")); - assertEquals(true, attributes.get("internal/role2")); - assertEquals(true, attributes.get("user-group")); - assertEquals(true, attributes.get("test")); - } - - @Test - void fieldMissing() { - JWTOpenIdClaims claims = buildClaims(null); - UserDetails userDetails = subject.createUserDetails(PROVIDER_NAME, new OpenIdTokenResponse(), claims); - assertDefaultRole(userDetails); - } - - @Test - void fieldIncompatible() { - JWTOpenIdClaims claims = buildClaims(GROUP_1); - UserDetails userDetails = subject.createUserDetails(PROVIDER_NAME, new OpenIdTokenResponse(), claims); - assertDefaultRole(userDetails); - } - - private GroupMapping buildGroupMapping(String name, String mapped) { - GroupMapping groupMapping = new GroupMapping(); - groupMapping.setName(name); - groupMapping.setGroups(Collections.singletonList(mapped)); - return groupMapping; - } - - private UserMapping buildUserMapping(String name, String group) { - UserMapping userMapping = new UserMapping(); - userMapping.setUsername(name); - userMapping.setGroups(Collections.singletonList(group)); - return userMapping; - } - - private void assertDefaultRole(UserDetails userDetails) { - assertContainsInAnyOrder(Arrays.asList("translated/user-group", "translated/test"), userDetails.getRoles()); - Map attributes = userDetails.getAttributes("roles", "username"); - assertEquals(true, attributes.get("user-group")); - assertEquals(true, attributes.get("test")); - } - - private void assertContainsInAnyOrder(Collection expected, Collection actual) { - Set expectedSet = new HashSet<>(expected); - Set actualSet = new HashSet<>(actual); - assertEquals(expectedSet, actualSet); - } - - private JWTOpenIdClaims buildClaims(Object roles) { - JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() - .claim(OpenIdClaims.CLAIMS_PREFERRED_USERNAME, USERNAME) - .claim(CLAIM_ROLES, roles) - .build(); - return new JWTOpenIdClaims(claimsSet); - } -} diff --git a/src/test/resources/application-filterregex.yml b/src/test/resources/application-filterregex.yml deleted file mode 100644 index 085ea0b05..000000000 --- a/src/test/resources/application-filterregex.yml +++ /dev/null @@ -1,15 +0,0 @@ -akhq: - security: - groups: - as-string: - roles: - - topic/read - attributes: - topics-filter-regexp: "test.*" - as-list: - roles: - - topic/read - attributes: - topics-filter-regexp: - - "item1" - - "item2" diff --git a/src/test/resources/application-groovy.yml b/src/test/resources/application-groovy.yml new file mode 100644 index 000000000..bcc5a3035 --- /dev/null +++ b/src/test/resources/application-groovy.yml @@ -0,0 +1,37 @@ +akhq: + security: + basic-auth: + - username: user + password: d74ff0ee8da3b9806b18c877dbf29bbde50b5bd8e4dad7a3a725000feb82e8f1 + groups: + - limited + - username: admin + password: d74ff0ee8da3b9806b18c877dbf29bbde50b5bd8e4dad7a3a725000feb82e8f1 + groups: + - admin + groovy: + enabled: true + file: | + package org.akhq.utils; + class GroovyCustomClaimProvider implements ClaimProvider { + @Override + AKHQClaimResponse generateClaim(AKHQClaimRequest request) { + AKHQClaimResponse a = AKHQClaimResponse.builder().build(); + a.roles = ["topic/read"] + if (request.username == "admin") { + a.attributes = [ + topicsFilterRegexp: [".*"], + connectsFilterRegexp: [".*"], + consumerGroupsFilterRegexp: [".*"] + ] + }else{ + a.attributes = [ + topicsFilterRegexp: ["single-topic"], + connectsFilterRegexp: ["single-connect"], + consumerGroupsFilterRegexp: ["single-consumer-group"] + ] + } + return a + } + } + diff --git a/src/test/resources/application-oidc.yml b/src/test/resources/application-oidc.yml new file mode 100644 index 000000000..671bac8cc --- /dev/null +++ b/src/test/resources/application-oidc.yml @@ -0,0 +1,136 @@ +micronaut: + application: + name: akhq + + security: + enabled: true + endpoints: + login: + path: "/login" + logout: + path: "/logout" + get-allowed: true + token: + jwt: + enabled: true + cookie: + enabled: true + signatures: + secret: + generator: + secret: d93YX6S7bukwTrmDLakBBWA3taHUkL4qkBqX2NYRJv5UQAjwCU4Kuey3mTTSgXAL + oauth2: + enabled: true + clients: + oidc: + grant-type: password + openid: + issuer: "http://no.url" + token: "fake-token" + +akhq: + server: + access-log: + enabled: false + + clients-defaults: + consumer: + properties: + group.id: Akhq + enable.auto.commit: "false" + + topic: + replication: 1 + retention: 86400000 + partition: 1 + internal-regexps: + - "^_.*$" + - "^.*_schemas$" + - "^.*connect-config$" + - "^.*connect-offsets$1" + - "^.*connect-status$" + stream-regexps: + - "^.*-changelog$" + - "^.*-repartition$" + - "^.*-rekey$" + + topic-data: + poll-timeout: 5000 + + pagination: + page-size: 5 + + security: + default-group: no-filter + groups: + admin: + name: admin + roles: + - topic/read + - topic/insert + - topic/delete + - topic/config/update + - node/read + - node/config/update + - topic/data/read + - topic/data/insert + - topic/data/delete + - group/read + - group/delete + - group/offsets/update + - registry/read + - registry/insert + - registry/update + - registry/delete + - registry/version/delete + - acls/read + - connect/read + - connect/insert + - connect/update + - connect/delete + - connect/state/update + limited: + name: limited + roles: + - topic/read + - topic/insert + - topic/delete + - registry/version/delete + attributes: + topics-filter-regexp: + - "test.*" + operator: + name: operator + roles: + - topic/read + - topic/data/read + - topic/data/insert + - topic/data/delete + attributes: + topics-filter-regexp: + - "test-operator.*" + no-filter: + name: no-filter + roles: + - topic/read + - topic/insert + - topic/delete + - registry/version/delete + oidc: + enabled: true + providers: + oidc: + username-field: preferred_username + groups-field: roles + default-group: topic-reader + groups: + - name: oidc-limited-group + groups: + - limited + - name: oidc-operator-group + groups: + - operator + users: + - username: user2 + groups: + - operator diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 41c6fef68..903c570c3 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -151,4 +151,4 @@ akhq: users: - username: user2 groups: - - operator \ No newline at end of file + - operator