killbill-memoizeit
Changes
profiles/killbill/src/main/java/org/killbill/billing/server/modules/KillBillShiroWebModule.java 4(+4 -0)
util/pom.xml 4(+4 -0)
Details
diff --git a/profiles/killbill/src/main/java/org/killbill/billing/server/modules/KillBillShiroWebModule.java b/profiles/killbill/src/main/java/org/killbill/billing/server/modules/KillBillShiroWebModule.java
index 49057fb..eaf9e55 100644
--- a/profiles/killbill/src/main/java/org/killbill/billing/server/modules/KillBillShiroWebModule.java
+++ b/profiles/killbill/src/main/java/org/killbill/billing/server/modules/KillBillShiroWebModule.java
@@ -44,6 +44,7 @@ import org.killbill.billing.util.glue.KillBillShiroModule;
import org.killbill.billing.util.security.shiro.dao.JDBCSessionDao;
import org.killbill.billing.util.security.shiro.realm.KillBillJdbcRealm;
import org.killbill.billing.util.security.shiro.realm.KillBillJndiLdapRealm;
+import org.killbill.billing.util.security.shiro.realm.KillBillOktaRealm;
import org.skife.config.ConfigSource;
import org.skife.config.ConfigurationObjectFactory;
@@ -87,6 +88,9 @@ public class KillBillShiroWebModule extends ShiroWebModuleWith435 {
if (KillBillShiroModule.isLDAPEnabled()) {
bindRealm().to(KillBillJndiLdapRealm.class).asEagerSingleton();
}
+ if (KillBillShiroModule.isOktaEnabled()) {
+ bindRealm().to(KillBillOktaRealm.class).asEagerSingleton();
+ }
bindListener(new AbstractMatcher<TypeLiteral<?>>() {
@Override
util/pom.xml 4(+4 -0)
diff --git a/util/pom.xml b/util/pom.xml
index a605008..9a46948 100644
--- a/util/pom.xml
+++ b/util/pom.xml
@@ -75,6 +75,10 @@
</dependency>
<dependency>
<groupId>com.ning</groupId>
+ <artifactId>async-http-client</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.ning</groupId>
<artifactId>compress-lzf</artifactId>
<scope>test</scope>
</dependency>
diff --git a/util/src/main/java/org/killbill/billing/util/config/definition/SecurityConfig.java b/util/src/main/java/org/killbill/billing/util/config/definition/SecurityConfig.java
index cfd6a84..24d3c2d 100644
--- a/util/src/main/java/org/killbill/billing/util/config/definition/SecurityConfig.java
+++ b/util/src/main/java/org/killbill/billing/util/config/definition/SecurityConfig.java
@@ -87,4 +87,23 @@ public interface SecurityConfig extends KillbillConfig {
@Default("false")
@Description("Whether to ignore SSL certificates checks")
public boolean disableShiroLDAPSSLCheck();
+
+ // Okta realm
+
+ @Config("org.killbill.security.okta.url")
+ @DefaultNull
+ @Description("Okta org full url")
+ public String getShiroOktaUrl();
+
+ @Config("org.killbill.security.okta.apiToken")
+ @DefaultNull
+ @Description("Okta API token")
+ public String getShiroOktaAPIToken();
+
+ @Config("org.killbill.security.okta.permissionsByGroup")
+ @Default("admin = *:*\n" +
+ "finance = invoice:*, payment:*\n" +
+ "support = entitlement:*, invoice:item_adjust")
+ @Description("Okta permissions by Okta group")
+ public String getShiroOktaPermissionsByGroup();
}
diff --git a/util/src/main/java/org/killbill/billing/util/glue/KillBillShiroModule.java b/util/src/main/java/org/killbill/billing/util/glue/KillBillShiroModule.java
index bd9ceb2..c103789 100644
--- a/util/src/main/java/org/killbill/billing/util/glue/KillBillShiroModule.java
+++ b/util/src/main/java/org/killbill/billing/util/glue/KillBillShiroModule.java
@@ -28,6 +28,7 @@ import org.killbill.billing.util.config.definition.RbacConfig;
import org.killbill.billing.util.security.shiro.dao.JDBCSessionDao;
import org.killbill.billing.util.security.shiro.realm.KillBillJdbcRealm;
import org.killbill.billing.util.security.shiro.realm.KillBillJndiLdapRealm;
+import org.killbill.billing.util.security.shiro.realm.KillBillOktaRealm;
import org.skife.config.ConfigSource;
import org.skife.config.ConfigurationObjectFactory;
@@ -38,6 +39,7 @@ import com.google.inject.binder.AnnotatedBindingBuilder;
public class KillBillShiroModule extends ShiroModule {
public static final String KILLBILL_LDAP_PROPERTY = "killbill.server.ldap";
+ public static final String KILLBILL_OKTA_PROPERTY = "killbill.server.okta";
public static final String KILLBILL_RBAC_PROPERTY = "killbill.server.rbac";
@@ -45,6 +47,10 @@ public class KillBillShiroModule extends ShiroModule {
return Boolean.parseBoolean(System.getProperty(KILLBILL_LDAP_PROPERTY, "false"));
}
+ public static boolean isOktaEnabled() {
+ return Boolean.parseBoolean(System.getProperty(KILLBILL_OKTA_PROPERTY, "false"));
+ }
+
public static boolean isRBACEnabled() {
return Boolean.parseBoolean(System.getProperty(KILLBILL_RBAC_PROPERTY, "true"));
}
@@ -81,6 +87,12 @@ public class KillBillShiroModule extends ShiroModule {
}
}
+ protected void configureOktaRealm() {
+ if (isOktaEnabled()) {
+ bindRealm().to(KillBillOktaRealm.class).asEagerSingleton();
+ }
+ }
+
@Override
protected void bindSecurityManager(final AnnotatedBindingBuilder<? super SecurityManager> bind) {
super.bindSecurityManager(bind);
diff --git a/util/src/main/java/org/killbill/billing/util/security/shiro/realm/KillBillOktaRealm.java b/util/src/main/java/org/killbill/billing/util/security/shiro/realm/KillBillOktaRealm.java
new file mode 100644
index 0000000..8d75bab
--- /dev/null
+++ b/util/src/main/java/org/killbill/billing/util/security/shiro/realm/KillBillOktaRealm.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2014-2017 Groupon, Inc
+ * Copyright 2014-2017 The Billing Project, LLC
+ *
+ * The Billing Project licenses this file to you under the Apache License, version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.killbill.billing.util.security.shiro.realm;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.shiro.authc.AuthenticationException;
+import org.apache.shiro.authc.AuthenticationInfo;
+import org.apache.shiro.authc.AuthenticationToken;
+import org.apache.shiro.authc.SimpleAuthenticationInfo;
+import org.apache.shiro.authc.UsernamePasswordToken;
+import org.apache.shiro.authz.AuthorizationException;
+import org.apache.shiro.authz.AuthorizationInfo;
+import org.apache.shiro.authz.SimpleAuthorizationInfo;
+import org.apache.shiro.config.Ini;
+import org.apache.shiro.config.Ini.Section;
+import org.apache.shiro.realm.AuthorizingRealm;
+import org.apache.shiro.subject.PrincipalCollection;
+import org.killbill.billing.util.config.definition.SecurityConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.ning.http.client.AsyncCompletionHandler;
+import com.ning.http.client.AsyncHttpClient;
+import com.ning.http.client.AsyncHttpClient.BoundRequestBuilder;
+import com.ning.http.client.AsyncHttpClientConfig;
+import com.ning.http.client.ListenableFuture;
+import com.ning.http.client.Response;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.inject.Inject;
+
+public class KillBillOktaRealm extends AuthorizingRealm {
+
+ private static final Logger log = LoggerFactory.getLogger(KillBillOktaRealm.class);
+ private static final ObjectMapper mapper = new ObjectMapper();
+ private static final int DEFAULT_TIMEOUT_SECS = 15;
+ private static final Splitter SPLITTER = Splitter.on(',').omitEmptyStrings().trimResults();
+
+ private final Map<String, Collection<String>> permissionsByGroup = Maps.newLinkedHashMap();
+
+ private final SecurityConfig securityConfig;
+ private final AsyncHttpClient httpClient;
+
+ @Inject
+ public KillBillOktaRealm(final SecurityConfig securityConfig) {
+ this.securityConfig = securityConfig;
+ this.httpClient = new AsyncHttpClient(new AsyncHttpClientConfig.Builder().setRequestTimeout(DEFAULT_TIMEOUT_SECS * 1000).build());
+
+ if (securityConfig.getShiroOktaPermissionsByGroup() != null) {
+ final Ini ini = new Ini();
+ // When passing properties on the command line, \n can be escaped
+ ini.load(securityConfig.getShiroOktaPermissionsByGroup().replace("\\n", "\n"));
+ for (final Section section : ini.getSections()) {
+ for (final String role : section.keySet()) {
+ final Collection<String> permissions = ImmutableList.<String>copyOf(SPLITTER.split(section.get(role)));
+ permissionsByGroup.put(role, permissions);
+ }
+ }
+ }
+ }
+
+ @Override
+ protected AuthorizationInfo doGetAuthorizationInfo(final PrincipalCollection principals) {
+ final String username = (String) getAvailablePrincipal(principals);
+ final String userId = findOktaUserId(username);
+ final Set<String> userGroups = findOktaGroupsForUser(userId);
+
+ final SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(userGroups);
+ final Set<String> stringPermissions = groupsPermissions(userGroups);
+ simpleAuthorizationInfo.setStringPermissions(stringPermissions);
+
+ return simpleAuthorizationInfo;
+ }
+
+ @Override
+ protected AuthenticationInfo doGetAuthenticationInfo(final AuthenticationToken token) throws AuthenticationException {
+ final UsernamePasswordToken upToken = (UsernamePasswordToken) token;
+ if (doAuthenticate(upToken)) {
+ // Credentials are valid
+ return new SimpleAuthenticationInfo(token.getPrincipal(), token.getCredentials(), getName());
+ } else {
+ throw new AuthenticationException("Okta authentication failed");
+ }
+ }
+
+ private boolean doAuthenticate(final UsernamePasswordToken upToken) {
+ final BoundRequestBuilder builder = httpClient.preparePost(securityConfig.getShiroOktaUrl() + "/api/v1/authn");
+ try {
+ final ImmutableMap<String, String> body = ImmutableMap.<String, String>of("username", upToken.getUsername(),
+ "password", String.valueOf(upToken.getPassword()));
+ builder.setBody(mapper.writeValueAsString(body));
+ } catch (final JsonProcessingException e) {
+ log.warn("Error while generating Okta payload");
+ throw new AuthenticationException(e);
+ }
+ builder.addHeader("Authorization", "SSWS " + securityConfig.getShiroOktaAPIToken());
+ builder.addHeader("Content-Type", "application/json; charset=UTF-8");
+ final Response response;
+ try {
+ final ListenableFuture<Response> futureStatus =
+ builder.execute(new AsyncCompletionHandler<Response>() {
+ @Override
+ public Response onCompleted(final Response response) throws Exception {
+ return response;
+ }
+ });
+ response = futureStatus.get(DEFAULT_TIMEOUT_SECS, TimeUnit.SECONDS);
+ } catch (final TimeoutException toe) {
+ log.warn("Timeout while connecting to Okta");
+ throw new AuthenticationException(toe);
+ } catch (final Exception e) {
+ log.warn("Error while connecting to Okta");
+ throw new AuthenticationException(e);
+ }
+
+ return isAuthenticated(response);
+ }
+
+ private boolean isAuthenticated(final Response oktaRawResponse) {
+ try {
+ final Map oktaResponse = mapper.readValue(oktaRawResponse.getResponseBodyAsStream(), Map.class);
+ if ("SUCCESS".equals(oktaResponse.get("status"))) {
+ return true;
+ } else {
+ log.warn("Okta authentication failed: " + oktaResponse);
+ return false;
+ }
+ } catch (final IOException e) {
+ log.warn("Unable to read response from Okta");
+ throw new AuthenticationException(e);
+ }
+ }
+
+ private String findOktaUserId(final String login) {
+ final String path;
+ try {
+ path = "/api/v1/users/" + URLEncoder.encode(login, "UTF-8");
+ } catch (final UnsupportedEncodingException e) {
+ // Should never happen
+ throw new IllegalStateException(e);
+ }
+
+ final Response oktaRawResponse = doGetRequest(path);
+ try {
+ final Map oktaResponse = mapper.readValue(oktaRawResponse.getResponseBodyAsStream(), Map.class);
+ return (String) oktaResponse.get("id");
+ } catch (final IOException e) {
+ log.warn("Unable to read response from Okta");
+ throw new AuthorizationException(e);
+ }
+ }
+
+ private Set<String> findOktaGroupsForUser(final String userId) {
+ final String path = "/api/v1/users/" + userId + "/groups";
+ final Response response = doGetRequest(path);
+ return getGroups(response);
+ }
+
+ private Response doGetRequest(final String path) {
+ final BoundRequestBuilder builder = httpClient.prepareGet(securityConfig.getShiroOktaUrl() + path);
+ builder.addHeader("Authorization", "SSWS " + securityConfig.getShiroOktaAPIToken());
+ builder.addHeader("Content-Type", "application/json; charset=UTF-8");
+ final Response response;
+ try {
+ final ListenableFuture<Response> futureStatus =
+ builder.execute(new AsyncCompletionHandler<Response>() {
+ @Override
+ public Response onCompleted(final Response response) throws Exception {
+ return response;
+ }
+ });
+ response = futureStatus.get(DEFAULT_TIMEOUT_SECS, TimeUnit.SECONDS);
+ } catch (final TimeoutException toe) {
+ log.warn("Timeout while connecting to Okta");
+ throw new AuthorizationException(toe);
+ } catch (final Exception e) {
+ log.warn("Error while connecting to Okta");
+ throw new AuthorizationException(e);
+ }
+ return response;
+ }
+
+ private Set<String> getGroups(final Response oktaRawResponse) {
+ try {
+ final List<Map> oktaResponse = mapper.readValue(oktaRawResponse.getResponseBodyAsStream(), new TypeReference<List<Map>>() {});
+ final Set<String> groups = new HashSet<String>();
+ for (final Map group : oktaResponse) {
+ final Object groupProfile = group.get("profile");
+ if (groupProfile != null && groupProfile instanceof Map) {
+ groups.add((String) ((Map) groupProfile).get("name"));
+ }
+ }
+ return groups;
+ } catch (final IOException e) {
+ log.warn("Unable to read response from Okta");
+ throw new AuthorizationException(e);
+ }
+ }
+
+ private Set<String> groupsPermissions(final Iterable<String> groups) {
+ final Set<String> permissions = new HashSet<String>();
+ for (final String group : groups) {
+ final Collection<String> permissionsForGroup = permissionsByGroup.get(group);
+ if (permissionsForGroup != null) {
+ permissions.addAll(permissionsForGroup);
+ }
+ }
+ return permissions;
+ }
+}
diff --git a/util/src/test/java/org/killbill/billing/util/security/shiro/realm/TestKillBillOktaRealm.java b/util/src/test/java/org/killbill/billing/util/security/shiro/realm/TestKillBillOktaRealm.java
new file mode 100644
index 0000000..edc374a
--- /dev/null
+++ b/util/src/test/java/org/killbill/billing/util/security/shiro/realm/TestKillBillOktaRealm.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2014-2017 Groupon, Inc
+ * Copyright 2014-2017 The Billing Project, LLC
+ *
+ * The Billing Project licenses this file to you under the Apache License, version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.killbill.billing.util.security.shiro.realm;
+
+import java.util.Properties;
+
+import org.apache.shiro.authc.AuthenticationInfo;
+import org.apache.shiro.authc.UsernamePasswordToken;
+import org.apache.shiro.authz.AuthorizationInfo;
+import org.apache.shiro.subject.SimplePrincipalCollection;
+import org.killbill.billing.util.UtilTestSuiteNoDB;
+import org.killbill.billing.util.config.definition.SecurityConfig;
+import org.skife.config.ConfigSource;
+import org.skife.config.ConfigurationObjectFactory;
+import org.skife.config.SimplePropertyConfigSource;
+import org.testng.annotations.Test;
+
+public class TestKillBillOktaRealm extends UtilTestSuiteNoDB {
+
+ @Test(groups = "external", enabled = false)
+ public void testCheckOktaConnection() throws Exception {
+ // Convenience method to verify your Okta connectivity
+ final Properties props = new Properties();
+ props.setProperty("org.killbill.security.okta.url", "https://dev-XXXXXX.oktapreview.com");
+ props.setProperty("org.killbill.security.okta.apiToken", "YYYYYY");
+ props.setProperty("org.killbill.security.okta.permissionsByGroup", "support-group: entitlement:*\n" +
+ "finance-group: invoice:*, payment:*\n" +
+ "ops-group: *:*");
+ final ConfigSource customConfigSource = new SimplePropertyConfigSource(props);
+ final SecurityConfig securityConfig = new ConfigurationObjectFactory(customConfigSource).build(SecurityConfig.class);
+ final KillBillOktaRealm oktaRealm = new KillBillOktaRealm(securityConfig);
+
+ final String username = "pierre";
+ final String password = "password";
+
+ // Check authentication
+ final UsernamePasswordToken token = new UsernamePasswordToken(username, password);
+ final AuthenticationInfo authenticationInfo = oktaRealm.getAuthenticationInfo(token);
+ System.out.println(authenticationInfo);
+
+ // Check permissions
+ final SimplePrincipalCollection principals = new SimplePrincipalCollection(username, username);
+ final AuthorizationInfo authorizationInfo = oktaRealm.doGetAuthorizationInfo(principals);
+ System.out.println("Roles: " + authorizationInfo.getRoles());
+ System.out.println("Permissions: " + authorizationInfo.getStringPermissions());
+ }
+}