From aa7e5b9157bb8c16bb31986018f45d9a865119bd Mon Sep 17 00:00:00 2001 From: Mitsuaki Ito Date: Tue, 3 Jan 2023 06:39:50 +0900 Subject: [PATCH] feat(auth): add support for github SSO / OAuth2 support (#1319) --- build.gradle | 3 + client/src/containers/Login/Login.jsx | 6 +- docs/.vuepress/config.js | 1 + .../configuration/authentifications/github.md | 48 +++++++++++ src/main/java/org/akhq/configs/Oauth.java | 30 +++++++ .../org/akhq/controllers/AkhqController.java | 19 +++++ .../java/org/akhq/models/GithubClaims.java | 23 +++++ .../modules/GithubAuthenticationMapper.java | 85 +++++++++++++++++++ .../org/akhq/utils/ClaimProviderType.java | 3 +- .../java/org/akhq/utils/GithubApiClient.java | 16 ++++ .../utils/LocalSecurityClaimProvider.java | 12 +++ 11 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 docs/docs/configuration/authentifications/github.md create mode 100644 src/main/java/org/akhq/configs/Oauth.java create mode 100644 src/main/java/org/akhq/models/GithubClaims.java create mode 100644 src/main/java/org/akhq/modules/GithubAuthenticationMapper.java create mode 100644 src/main/java/org/akhq/utils/GithubApiClient.java diff --git a/build.gradle b/build.gradle index cafc5cffd..8b4ad8a2c 100644 --- a/build.gradle +++ b/build.gradle @@ -132,6 +132,9 @@ dependencies { //AWS MSK IAM Auth implementation group: 'software.amazon.msk', name: 'aws-msk-iam-auth', version: '1.1.5' + + // https://mvnrepository.com/artifact/io.projectreactor/reactor-core + implementation group: 'io.projectreactor', name: 'reactor-core', version: '3.5.1' } /**********************************************************************************************************************\ diff --git a/client/src/containers/Login/Login.jsx b/client/src/containers/Login/Login.jsx index e9632bfb4..3448d2da2 100644 --- a/client/src/containers/Login/Login.jsx +++ b/client/src/containers/Login/Login.jsx @@ -19,7 +19,8 @@ class Login extends Form { errors: {}, config: { formEnabled: true, - oidcAuths: [] + oidcAuths: [], + oauthAuths: [] } }; @@ -157,7 +158,7 @@ class Login extends Form { } render() { - const { formEnabled, oidcAuths } = this.state.config; + const { formEnabled, oidcAuths, oauthAuths } = this.state.config; return (
@@ -177,6 +178,7 @@ class Login extends Form { {formEnabled && this._renderForm()} {formEnabled && oidcAuths && this._renderSeparator()} {oidcAuths && this._renderOidc(oidcAuths)} + {oauthAuths && this._renderOidc(oauthAuths)}
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index f37801a34..8746234f2 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -54,6 +54,7 @@ module.exports = { '/docs/configuration/authentifications/basic-auth.md', '/docs/configuration/authentifications/aws-iam-auth.md', '/docs/configuration/authentifications/oidc.md', + '/docs/configuration/authentifications/github.md', '/docs/configuration/authentifications/ldap.md', '/docs/configuration/authentifications/header.md', '/docs/configuration/authentifications/external.md', diff --git a/docs/docs/configuration/authentifications/github.md b/docs/docs/configuration/authentifications/github.md new file mode 100644 index 000000000..b08d09c01 --- /dev/null +++ b/docs/docs/configuration/authentifications/github.md @@ -0,0 +1,48 @@ + +# GitHub SSO / OAuth2 +To enable GitHub SSO in the application, you'll first have to enable OAuth2 in micronaut: + +```yaml +micronaut: + security: + enabled: true + oauth2: + enabled: true + clients: + github: + client-id: "" + client-secret: "" + scopes: + - user:email + - read:user + authorization: + url: https://github.com/login/oauth/authorize + token: + url: https://github.com/login/oauth/access_token + auth-method: client-secret-post +``` + +To further tell AKHQ to display GitHub SSO options on the login page and customize claim mapping, configure Oauth in the AKHQ config: + +```yaml +akhq: + security: + default-group: no-roles + oauth2: + enabled: true + providers: + github: + label: "Login with GitHub" + username-field: login + users: + - username: franz + groups: + # the corresponding akhq groups (eg. topic-reader/writer or akhq default groups like admin/reader/no-role) + - topic-reader + - topic-writer +``` + +The username field can be any string field, the roles field has to be a JSON array. + +## References +https://micronaut-projects.github.io/micronaut-security/latest/guide/#oauth2-configuration diff --git a/src/main/java/org/akhq/configs/Oauth.java b/src/main/java/org/akhq/configs/Oauth.java new file mode 100644 index 000000000..af179d7ba --- /dev/null +++ b/src/main/java/org/akhq/configs/Oauth.java @@ -0,0 +1,30 @@ +package org.akhq.configs; + +import io.micronaut.context.annotation.ConfigurationProperties; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@ConfigurationProperties("akhq.security.oauth2") +@Data +public class Oauth { + private boolean enabled; + private Map providers; + + @Data + public static class Provider { + private String label = "Login with OAuth"; + private String usernameField = "login"; + private String groupsField = "organizations_url"; + private String defaultGroup; + private List groups = new ArrayList<>(); + private List users = new ArrayList<>(); + } + + public Provider getProvider(String key) { + providers.putIfAbsent(key, new Provider()); + return providers.get(key); + } +} diff --git a/src/main/java/org/akhq/controllers/AkhqController.java b/src/main/java/org/akhq/controllers/AkhqController.java index 97b8fbdf4..088fe2185 100644 --- a/src/main/java/org/akhq/controllers/AkhqController.java +++ b/src/main/java/org/akhq/controllers/AkhqController.java @@ -39,6 +39,9 @@ public class AkhqController extends AbstractController { @Inject private Oidc oidc; + @Inject + private Oauth oauth; + @Inject private UIOptions uIOptions; @@ -80,6 +83,13 @@ public AuthDefinition auths() { .collect(Collectors.toList()); } + if (oauth.isEnabled()) { + authDefinition.oauthAuths = oauth.getProviders().entrySet() + .stream() + .map(e -> new OauthAuth(e.getKey(), e.getValue().getLabel())) + .collect(Collectors.toList()); + } + if (applicationContext.containsBean(SecurityService.class)) { authDefinition.loginEnabled = true; // Display login form if there are LocalUsers OR Ldap is enabled @@ -180,6 +190,7 @@ public static class AuthDefinition { private boolean loginEnabled; private boolean formEnabled; private List oidcAuths; + private List oauthAuths; private String version; } @@ -191,6 +202,14 @@ public static class OidcAuth { private String label; } + @AllArgsConstructor + @NoArgsConstructor + @Getter + public static class OauthAuth { + private String key; + private String label; + } + @AllArgsConstructor @NoArgsConstructor @Getter diff --git a/src/main/java/org/akhq/models/GithubClaims.java b/src/main/java/org/akhq/models/GithubClaims.java new file mode 100644 index 000000000..7c68f03c0 --- /dev/null +++ b/src/main/java/org/akhq/models/GithubClaims.java @@ -0,0 +1,23 @@ +package org.akhq.models; + +import io.micronaut.security.token.Claims; + +import java.util.HashMap; +import java.util.Set; + +public class GithubClaims extends HashMap implements Claims { + @Override + public Object get(String name) { + return super.get(name); + } + + @Override + public Set names() { + return super.keySet(); + } + + @Override + public boolean contains(String name) { + return super.containsKey(name); + } +} \ No newline at end of file diff --git a/src/main/java/org/akhq/modules/GithubAuthenticationMapper.java b/src/main/java/org/akhq/modules/GithubAuthenticationMapper.java new file mode 100644 index 000000000..fd477d852 --- /dev/null +++ b/src/main/java/org/akhq/modules/GithubAuthenticationMapper.java @@ -0,0 +1,85 @@ +package org.akhq.modules; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.util.StringUtils; +import io.micronaut.security.authentication.AuthenticationResponse; +import io.micronaut.security.oauth2.endpoint.authorization.state.State; +import io.micronaut.security.oauth2.endpoint.token.response.OauthAuthenticationMapper; +import io.micronaut.security.oauth2.endpoint.token.response.TokenResponse; +import jakarta.inject.Named; +import org.akhq.configs.Oauth; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.akhq.models.GithubClaims; +import org.akhq.utils.*; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import java.util.*; +import java.util.stream.Collectors; + +@Singleton +@Named("github") +@Requires(property = "akhq.security.oauth2.enabled", value = StringUtils.TRUE) +public class GithubAuthenticationMapper implements OauthAuthenticationMapper { + @Inject + private Oauth oauth; + @Inject + private GithubApiClient apiClient; + @Inject + private ClaimProvider claimProvider; + + @Override + public Publisher createAuthenticationResponse(TokenResponse tokenResponse, @Nullable State state) { + return Flux.from(apiClient.getUser("token " + tokenResponse.getAccessToken())) + .map(user -> { + ClaimRequest request = ClaimRequest.builder() + .providerType(ClaimProviderType.OAUTH) + .providerName("github") + .username(getUsername(oauth.getProvider("github"), user)) + .groups(getOauthGroups(oauth.getProvider("github"), user)) + .build(); + + ClaimResponse claim = claimProvider.generateClaim(request); + + return AuthenticationResponse.success(getUsername(oauth.getProvider("github"), user), claim.getRoles(), claim.getAttributes()); + }); + } + + /** + * Tries to read the username from the configured username field. + * + * @param provider The OAuth provider + * @param user The OAuth claims + * @return The username to set in the {@link io.micronaut.security.authentication.Authentication} + */ + protected String getUsername(Oauth.Provider provider, GithubClaims user) { + String userNameField = provider.getUsernameField(); + return Objects.toString(user.get(userNameField)); + } + + /** + * 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 OAuth provider configuration + * @param user The OAuth claims + * @return The groups from oauth + */ + protected List getOauthGroups(Oauth.Provider provider, GithubClaims user) { + List groups = new ArrayList<>(); + if (user.contains(provider.getGroupsField())) { + Object groupsField = user.get(provider.getGroupsField()); + if (groupsField instanceof Collection) { + groups = ((Collection) groupsField) + .stream() + .map(Objects::toString) + .collect(Collectors.toList()); + } else if (groupsField instanceof String) { + groups.add((String) groupsField); + } + } + return groups; + } +} \ No newline at end of file diff --git a/src/main/java/org/akhq/utils/ClaimProviderType.java b/src/main/java/org/akhq/utils/ClaimProviderType.java index 158fc8e9b..1a6f779c1 100644 --- a/src/main/java/org/akhq/utils/ClaimProviderType.java +++ b/src/main/java/org/akhq/utils/ClaimProviderType.java @@ -4,5 +4,6 @@ public enum ClaimProviderType { HEADER, BASIC_AUTH, LDAP, - OIDC + OIDC, + OAUTH } diff --git a/src/main/java/org/akhq/utils/GithubApiClient.java b/src/main/java/org/akhq/utils/GithubApiClient.java new file mode 100644 index 000000000..08ab11f82 --- /dev/null +++ b/src/main/java/org/akhq/utils/GithubApiClient.java @@ -0,0 +1,16 @@ +package org.akhq.utils; + +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Header; +import io.micronaut.http.client.annotation.Client; +import org.akhq.models.GithubClaims; +import org.reactivestreams.Publisher; + +@Header(name = "User-Agent", value = "Micronaut") +@Header(name = "Accept", value = "application/vnd.github.v3+json, application/json") +@Client("https://api.github.com") +public interface GithubApiClient { + + @Get("/user") + Publisher getUser(@Header("Authorization") String authorization); +} \ No newline at end of file diff --git a/src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java b/src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java index 4fee841cc..fd5c35b0f 100644 --- a/src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java +++ b/src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java @@ -23,6 +23,8 @@ public class LocalSecurityClaimProvider implements ClaimProvider { Ldap ldapProperties; @Inject Oidc oidcProperties; + @Inject + Oauth oauthProperties; @Override public ClaimResponse generateClaim(ClaimRequest request) { @@ -63,6 +65,16 @@ public ClaimResponse generateClaim(ClaimRequest request) { defaultGroup = provider.getDefaultGroup(); akhqGroups.addAll(mapToAkhqGroups(request.getUsername(), request.getGroups(), groupMappings, userMappings, defaultGroup)); break; + case OAUTH: + // we need to convert from OAUTH login name to AKHQ groups to find the roles and attributes + // using akhq.security.oauth2.groups and akhq.security.oauth2.users + // as well as akhq.security.oauth2.default-group + Oauth.Provider oauthPropertiesProvider = oauthProperties.getProvider(request.getProviderName()); + userMappings = oauthPropertiesProvider.getUsers(); + groupMappings = oauthPropertiesProvider.getGroups(); + defaultGroup = oauthPropertiesProvider.getDefaultGroup(); + akhqGroups.addAll(mapToAkhqGroups(request.getUsername(), request.getGroups(), groupMappings, userMappings, defaultGroup)); + break; default: break; }