Skip to content

Commit

Permalink
shiro: initial Okta integration
Browse files Browse the repository at this point in the history
Signed-off-by: Pierre-Alexandre Meyer <pierre@mouraf.org>
  • Loading branch information
pierre committed Aug 22, 2017
1 parent 5937556 commit 15ebce2
Show file tree
Hide file tree
Showing 6 changed files with 342 additions and 0 deletions.
Expand Up @@ -44,6 +44,7 @@
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;

Expand Down Expand Up @@ -87,6 +88,9 @@ private void configureShiroForRBAC() {
if (KillBillShiroModule.isLDAPEnabled()) {
bindRealm().to(KillBillJndiLdapRealm.class).asEagerSingleton();
}
if (KillBillShiroModule.isOktaEnabled()) {
bindRealm().to(KillBillOktaRealm.class).asEagerSingleton();
}

bindListener(new AbstractMatcher<TypeLiteral<?>>() {
@Override
Expand Down
4 changes: 4 additions & 0 deletions util/pom.xml
Expand Up @@ -73,6 +73,10 @@
<groupId>com.google.inject.extensions</groupId>
<artifactId>guice-multibindings</artifactId>
</dependency>
<dependency>
<groupId>com.ning</groupId>
<artifactId>async-http-client</artifactId>
</dependency>
<dependency>
<groupId>com.ning</groupId>
<artifactId>compress-lzf</artifactId>
Expand Down
Expand Up @@ -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();
}
Expand Up @@ -28,6 +28,7 @@
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;

Expand All @@ -38,13 +39,18 @@
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";


public static boolean isLDAPEnabled() {
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"));
}
Expand Down Expand Up @@ -81,6 +87,12 @@ protected void configureLDAPRealm() {
}
}

protected void configureOktaRealm() {
if (isOktaEnabled()) {
bindRealm().to(KillBillOktaRealm.class).asEagerSingleton();
}
}

@Override
protected void bindSecurityManager(final AnnotatedBindingBuilder<? super SecurityManager> bind) {
super.bindSecurityManager(bind);
Expand Down
@@ -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;
}
}
@@ -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());
}
}

1 comment on commit 15ebce2

@sbrossie
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Please sign in to comment.