diff --git a/src/main/java/org/graylog/plugins/auth/sso/HeaderRoleUtil.java b/src/main/java/org/graylog/plugins/auth/sso/HeaderRoleUtil.java new file mode 100644 index 0000000..17e070e --- /dev/null +++ b/src/main/java/org/graylog/plugins/auth/sso/HeaderRoleUtil.java @@ -0,0 +1,86 @@ +/** + * This file is part of Graylog Archive. + * + * Graylog Archive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Graylog Archive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Graylog Archive. If not, see . + */ +package org.graylog.plugins.auth.sso; + +import org.graylog2.database.NotFoundException; +import org.graylog2.shared.users.Role; +import org.graylog2.users.RoleService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; +import javax.ws.rs.core.MultivaluedMap; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Encapsulating common header and role parsing. + */ +abstract class HeaderRoleUtil { + private static final Logger LOG = LoggerFactory.getLogger(HeaderRoleUtil.class); + + static Optional headerValue(MultivaluedMap headers, @Nullable String headerName) { + if (headerName == null) { + return Optional.empty(); + } + return Optional.ofNullable(headers.getFirst(headerName.toLowerCase())); + } + + static Optional> headerValues(MultivaluedMap headers, + @Nullable String headerNamePrefix) { + if (headerNamePrefix == null) { + return Optional.empty(); + } + Set keys = headers.keySet(); + List headerValues = keys.stream().filter(key -> key.startsWith(headerNamePrefix.toLowerCase())) + .map(key -> headers.getFirst(key)).collect(Collectors.toList()); + + return Optional.ofNullable(headerValues); + } + + static @NotNull Set csv(@NotNull List values) { + Set uniqValues = new HashSet<>(); + for (String csString : values) { + String[] valueArr = csString.split(","); + + for (String value : valueArr) { + uniqValues.add(value.trim()); + } + } + return uniqValues; + } + + static @NotNull Set getRoleIds(RoleService roleService, @NotNull Set roleNames) { + Set roleIds = new HashSet<>(); + for (String roleName : roleNames) { + if (roleService.exists(roleName)) { + try { + Role r = roleService.load(roleName); + roleIds.add(r.getId()); + } catch (NotFoundException e) { + LOG.error("Role {} not found, but it existed before", roleName); + } + } + } + return roleIds; + } + +} diff --git a/src/main/java/org/graylog/plugins/auth/sso/SsoAuthModule.java b/src/main/java/org/graylog/plugins/auth/sso/SsoAuthModule.java index 6ee4c35..9d0c102 100644 --- a/src/main/java/org/graylog/plugins/auth/sso/SsoAuthModule.java +++ b/src/main/java/org/graylog/plugins/auth/sso/SsoAuthModule.java @@ -17,9 +17,12 @@ package org.graylog.plugins.auth.sso; import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; import org.graylog.plugins.auth.sso.audit.SsoAuthAuditEventTypes; import org.graylog2.plugin.PluginModule; +import javax.ws.rs.container.DynamicFeature; + /** * Extend the PluginModule abstract class here to add you plugin to the system. */ @@ -31,5 +34,8 @@ protected void configure() { addRestResource(SsoConfigResource.class); addPermissions(SsoAuthPermissions.class); addAuditEventTypes(SsoAuthAuditEventTypes.class); + + Multibinder> setBinder = jerseyDynamicFeatureBinder(); + setBinder.addBinding().toInstance(SsoSecurityBinding.class); } } diff --git a/src/main/java/org/graylog/plugins/auth/sso/SsoAuthRealm.java b/src/main/java/org/graylog/plugins/auth/sso/SsoAuthRealm.java index 4eb5256..d5874d3 100644 --- a/src/main/java/org/graylog/plugins/auth/sso/SsoAuthRealm.java +++ b/src/main/java/org/graylog/plugins/auth/sso/SsoAuthRealm.java @@ -38,17 +38,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Named; import javax.ws.rs.core.MultivaluedMap; import java.net.UnknownHostException; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; + +import static org.graylog.plugins.auth.sso.HeaderRoleUtil.*; public class SsoAuthRealm extends AuthenticatingRealm { private static final Logger LOG = LoggerFactory.getLogger(SsoAuthRealm.class); @@ -187,34 +186,13 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) return null; } - protected Set csv(List values) { - Set uniqValues = new HashSet<>(); - for (String csString : values) { - String[] valueArr = csString.split(","); - - for (String value : valueArr) { - uniqValues.add(value.trim()); - } - } - return uniqValues; - } - protected void syncUserRoles(List roleCsv, User user) throws ValidationException { Set roleNames = csv(roleCsv); Set existingRoles = user.getRoleIds(); - Set syncedRoles = new HashSet<>(); - for (String roleName : roleNames) { - if (roleService.exists(roleName)) { - try { - Role r = roleService.load(roleName); - syncedRoles.add(r.getId()); - } catch (NotFoundException e) { - LOG.error("Role {} not found, but it existed before", roleName); - } - } - } - if (existingRoles != null && !existingRoles.equals(syncedRoles)) { + Set syncedRoles = getRoleIds(roleService, roleNames); + + if (!existingRoles.equals(syncedRoles)) { user.setRoleIds(syncedRoles); userService.save(user); } @@ -234,22 +212,4 @@ boolean inTrustedSubnets(String remoteAddr) { }); } - private Optional headerValue(MultivaluedMap headers, @Nullable String headerName) { - if (headerName == null) { - return Optional.empty(); - } - return Optional.ofNullable(headers.getFirst(headerName.toLowerCase())); - } - - protected Optional> headerValues(MultivaluedMap headers, - @Nullable String headerNamePrefix) { - if (headerNamePrefix == null) { - return Optional.empty(); - } - Set keys = headers.keySet(); - List headerValues = keys.stream().filter(key -> key.startsWith(headerNamePrefix.toLowerCase())) - .map(key -> headers.getFirst(key)).collect(Collectors.toList()); - - return Optional.ofNullable(headerValues); - } } diff --git a/src/main/java/org/graylog/plugins/auth/sso/SsoAuthenticationFilter.java b/src/main/java/org/graylog/plugins/auth/sso/SsoAuthenticationFilter.java new file mode 100644 index 0000000..f6eb3d9 --- /dev/null +++ b/src/main/java/org/graylog/plugins/auth/sso/SsoAuthenticationFilter.java @@ -0,0 +1,136 @@ +/** + * This file is part of Graylog Archive. + * + * Graylog Archive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Graylog Archive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Graylog Archive. If not, see . + */ +package org.graylog.plugins.auth.sso; + +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.session.Session; +import org.apache.shiro.subject.Subject; +import org.graylog2.plugin.cluster.ClusterConfigService; +import org.graylog2.plugin.database.users.User; +import org.graylog2.security.realm.LdapUserAuthenticator; +import org.graylog2.shared.users.UserService; +import org.graylog2.users.RoleService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Priority; +import javax.inject.Inject; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.Priorities; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.core.Response; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.graylog.plugins.auth.sso.HeaderRoleUtil.*; + +/** + * Checking the session if it still matches the user and the roles in the HTTP request SSO headers. + * This is necessary as {@link SsoAuthRealm} is only called at the creation of the session. + *

+ * DESIGN DECISIONS + *

+ * #1 This class doesn't honor the additional trust proxies. This leads to closing more session than necessary, + * but avoids duplicating the logic here with the danger of failing to call it identically. + *

+ * #2 If role syncing with Graylog is activated, the first request of a session checks roles against the user database. + * If this matches, the contents of the role header are cached in the session and checked again if the role headers of a request change. + * If this doesn't match, the user is logged out and the session is terminated. In a SSO system this will trigger a re-login + * and a re-sync of the user roles. + * + */ +/* This needs to have a lower priority than the {@link org.graylog2.shared.security.ShiroAuthenticationFilter} + * so that the {@link Subject} is already populated. */ +@Priority(Priorities.AUTHENTICATION + 1) +public class SsoAuthenticationFilter implements ContainerRequestFilter { + private static final Logger LOG = LoggerFactory.getLogger(SsoAuthenticationFilter.class); + private static final String VERIFIED_ROLES = SsoAuthenticationFilter.class.getName() + ".VERIFIED_USERS"; + + private final ClusterConfigService clusterConfigService; + private final RoleService roleService; + private final UserService userService; + private final LdapUserAuthenticator ldapAuthenticator; + + @Inject + public SsoAuthenticationFilter(ClusterConfigService clusterConfigService, RoleService roleService, UserService userService, LdapUserAuthenticator ldapAuthenticator) { + this.clusterConfigService = clusterConfigService; + this.roleService = roleService; + this.userService = userService; + this.ldapAuthenticator = ldapAuthenticator; + } + + @Override + public void filter(ContainerRequestContext containerRequestContext) { + + final SsoAuthConfig config = clusterConfigService.getOrDefault( + SsoAuthConfig.class, + SsoAuthConfig.defaultConfig("")); + + Subject subject = SecurityUtils.getSubject(); + // the subject needs to be inspected only if there is a session. + // If there is no session, Shiro has checked the headers on this request already + if (subject.getSession(false) != null) { + Session session = subject.getSession(); + final String usernameHeader = config.usernameHeader(); + final Optional userNameOption = headerValue(containerRequestContext.getHeaders(), usernameHeader); + // is the header with the user name is present ... + if (userNameOption.isPresent()) { + String username = userNameOption.get(); + if (!username.equals(subject.getPrincipal())) { + // terminate the session and return "unauthorized" for this request + LOG.warn("terminating session of {} as new user {} appears in the header", subject.getPrincipal(), username); + subject.logout(); + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); + } + if (config.syncRoles()) { + Optional> rolesList = headerValues(containerRequestContext.getHeaders(), config.rolesHeader()); + if (rolesList.isPresent() && !rolesList.get().equals(session.getAttribute(VERIFIED_ROLES))) { + User user = null; + if (ldapAuthenticator.isEnabled()) { + user = ldapAuthenticator.syncLdapUser(username); + } + if (user == null) { + user = userService.load(username); + } + if (user == null) { + LOG.error("user {} not found", + subject.getPrincipal()); + subject.logout(); + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); + } + Set roleNames = csv(rolesList.get()); + Set existingRoles = user.getRoleIds(); + + Set roleIds = getRoleIds(roleService, roleNames); + if (!existingRoles.equals(roleIds)) { + // terminate the session and return "unauthorized" for this request + LOG.warn("terminating session of user {} as roles in user differ from roles in header ({})", + subject.getPrincipal(), + roleNames); + subject.logout(); + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); + } + session.setAttribute(VERIFIED_ROLES, rolesList.get()); + } + } + } + } + } + +} diff --git a/src/main/java/org/graylog/plugins/auth/sso/SsoSecurityBinding.java b/src/main/java/org/graylog/plugins/auth/sso/SsoSecurityBinding.java new file mode 100644 index 0000000..ef1fb62 --- /dev/null +++ b/src/main/java/org/graylog/plugins/auth/sso/SsoSecurityBinding.java @@ -0,0 +1,55 @@ +/** + * This file is part of Graylog. + * + * Graylog is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Graylog is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Graylog. If not, see . + */ +package org.graylog.plugins.auth.sso; + +import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.apache.shiro.authz.annotation.RequiresGuest; +import org.graylog2.shared.security.ShiroSecurityBinding; +import org.graylog2.shared.security.ShiroSecurityContextFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.container.DynamicFeature; +import javax.ws.rs.container.ResourceInfo; +import javax.ws.rs.core.FeatureContext; +import java.lang.reflect.Method; + +/** + * Adding the {@link SsoAuthenticationFilter} to each resource that requires authentication. + * This is mirroring the efforts in {@link ShiroSecurityBinding} + */ +public class SsoSecurityBinding implements DynamicFeature { + private static final Logger LOG = LoggerFactory.getLogger(SsoSecurityBinding.class); + + @Override + public void configure(ResourceInfo resourceInfo, FeatureContext context) { + final Class resourceClass = resourceInfo.getResourceClass(); + final Method resourceMethod = resourceInfo.getResourceMethod(); + + context.register(ShiroSecurityContextFilter.class); + + if (resourceMethod.isAnnotationPresent(RequiresAuthentication.class) || resourceClass.isAnnotationPresent(RequiresAuthentication.class)) { + if (resourceMethod.isAnnotationPresent(RequiresGuest.class)) { + LOG.debug("Resource method {}#{} is marked as unauthenticated, skipping setting filter.", resourceClass.getCanonicalName(), resourceMethod.getName()); + } else { + LOG.debug("Resource method {}#{} requires an authenticated user.", resourceClass.getCanonicalName(), resourceMethod.getName()); + context.register(SsoAuthenticationFilter.class); + } + } + + } +} diff --git a/src/test/java/org/graylog/plugins/auth/sso/HeaderRoleUtilTest.java b/src/test/java/org/graylog/plugins/auth/sso/HeaderRoleUtilTest.java new file mode 100644 index 0000000..a8b6cc4 --- /dev/null +++ b/src/test/java/org/graylog/plugins/auth/sso/HeaderRoleUtilTest.java @@ -0,0 +1,51 @@ +/** + * This file is part of Graylog Archive. + * + * Graylog Archive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Graylog Archive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Graylog Archive. If not, see . + */ +package org.graylog.plugins.auth.sso; + +import org.junit.Test; + +import javax.ws.rs.core.MultivaluedHashMap; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.*; + +public class HeaderRoleUtilTest { + + @Test + public void testHeaderValuesCsv() { + MultivaluedHashMap m = new MultivaluedHashMap<>(); + m.put("roles", Arrays.asList(new String[]{"role1, role2, role3"})); + m.put("roles_1", Arrays.asList(new String[]{"asdf1"})); + m.put("roles_2", Arrays.asList(new String[]{"asdf2"})); + + Optional> s = HeaderRoleUtil.headerValues(m, "Roles"); + Set actual = HeaderRoleUtil.csv(s.get()); + List expected = Arrays.asList(new String[]{"role1","role2","role3","asdf1","asdf2"}); + + assertEquals(actual.size(), expected.size()); + for ( String role : expected) { + if ( !actual.contains(role)) { + fail("Role [" + role + "] expected, but not in result: " + actual); + } + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/graylog/plugins/auth/sso/SsoAuthRealmTest.java b/src/test/java/org/graylog/plugins/auth/sso/SsoAuthRealmTest.java index 1675c61..24d22fc 100644 --- a/src/test/java/org/graylog/plugins/auth/sso/SsoAuthRealmTest.java +++ b/src/test/java/org/graylog/plugins/auth/sso/SsoAuthRealmTest.java @@ -187,27 +187,6 @@ public void testDefaultDomainNotSet() { assertThat(user.getEmail()).isEqualTo("horst@localhost"); } - @Test - public void testHeaderValuesCsv() { - MultivaluedHashMap m = new MultivaluedHashMap<>(); - m.put("roles", Arrays.asList(new String[]{"role1, role2, role3"})); - m.put("roles_1", Arrays.asList(new String[]{"asdf1"})); - m.put("roles_2", Arrays.asList(new String[]{"asdf2"})); - - SsoAuthRealm r = new SsoAuthRealm(null, null, null, null, Collections.emptySet()); - Optional> s = r.headerValues(m, "Roles"); - Set actual = r.csv(s.get()); - List expected = Arrays.asList(new String[]{"role1","role2","role3","asdf1","asdf2"}); - - assertEquals(actual.size(), expected.size()); - for ( String role : expected) { - if ( !actual.contains(role)) { - fail("Role [" + role + "] expected, but not in result: " + actual); - } - } - - } - @Test public void testSyncRoles() throws Exception { List rolesCsv = Arrays.asList(new String[]{"role1","role2", "role3, role4"}); diff --git a/src/test/java/org/graylog/plugins/auth/sso/SsoAuthenticationFilterTest.java b/src/test/java/org/graylog/plugins/auth/sso/SsoAuthenticationFilterTest.java new file mode 100644 index 0000000..0ff2cce --- /dev/null +++ b/src/test/java/org/graylog/plugins/auth/sso/SsoAuthenticationFilterTest.java @@ -0,0 +1,211 @@ +/** + * This file is part of Graylog Archive. + * + * Graylog Archive is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Graylog Archive is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Graylog Archive. If not, see . + */ +package org.graylog.plugins.auth.sso; + +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.session.Session; +import org.apache.shiro.subject.Subject; +import org.glassfish.jersey.message.internal.MediaTypeProvider; +import org.glassfish.jersey.message.internal.OutboundJaxrsResponse; +import org.glassfish.jersey.message.internal.OutboundMessageContext; +import org.graylog2.database.NotFoundException; +import org.graylog2.plugin.cluster.ClusterConfigService; +import org.graylog2.plugin.database.users.User; +import org.graylog2.security.realm.LdapUserAuthenticator; +import org.graylog2.shared.users.Role; +import org.graylog2.shared.users.UserService; +import org.graylog2.users.RoleService; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.ext.RuntimeDelegate; +import java.util.*; + +import static org.mockito.Mockito.*; + +public class SsoAuthenticationFilterTest { + + private static final String USER_HEADER = "Remote-User"; + private static final String ROLE_HEADER = "Roles"; + + @Rule + public ExpectedException exceptionRule = ExpectedException.none(); + + private SsoAuthConfig config = SsoAuthConfig.builder() + .usernameHeader(USER_HEADER) + .autoCreateUser(false) + .requireTrustedProxies(false) + .syncRoles(true) + .rolesHeader(ROLE_HEADER) + .autoBuild(); + + private SsoAuthenticationFilter filter; + private Subject subject; + private MultivaluedMap headers; + private ContainerRequestContext containerRequest; + private UserService userService; + private RoleService roleService; + + @Before + public void setup() { + // ClusterConfigService + ClusterConfigService clusterConfigService = mock(ClusterConfigService.class); + when(clusterConfigService.getOrDefault( + SsoAuthConfig.class, + SsoAuthConfig.defaultConfig(""))).thenReturn(config); + + // RoleService + roleService = mock(RoleService.class); + + // UserService + userService = mock(UserService.class); + + // LdapUserAuthenticator + LdapUserAuthenticator ldapAuthenticator = mock(LdapUserAuthenticator.class); + + // SsoAuthenticationFilter = System under Test + filter = new SsoAuthenticationFilter(clusterConfigService, roleService, userService, ldapAuthenticator); + + // SecurityManager + SecurityManager securityManager = mock(SecurityManager.class); + SecurityUtils.setSecurityManager(securityManager); + + // Subject + subject = mock(Subject.class); + when(securityManager.createSubject(any())).thenReturn(subject); + + // Session + Session session = mock(Session.class); + when(subject.getSession(anyBoolean())).thenReturn(session); + when(subject.getSession()).thenReturn(session); + Map attributes = new HashMap<>(); + doAnswer(invocation -> { + attributes.put(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(session).setAttribute(any(), any()); + doAnswer(invocation -> attributes.get(invocation.getArgument(0))).when(session).getAttribute(any()); + + // ContainerRequestContext + containerRequest = mock(ContainerRequestContext.class); + headers = new MultivaluedHashMap<>(); + when(containerRequest.getHeaders()).thenReturn(headers); + + // RuntimeDelegate + RuntimeDelegate runtimeDelegate = mock(RuntimeDelegate.class); + RuntimeDelegate.setInstance(runtimeDelegate); + when(runtimeDelegate.createHeaderDelegate(MediaType.class)).thenReturn(new MediaTypeProvider()); + when(runtimeDelegate.createResponseBuilder()) + .thenAnswer(invocation -> new OutboundJaxrsResponse.Builder(new OutboundMessageContext())); + } + + @After + public void cleanup() { + RuntimeDelegate.setInstance(null); + SecurityUtils.setSecurityManager(null); + } + + + @Test + public void shouldAcceptWhenSubjectNameIsMatches() { + // given... + headers.put(USER_HEADER.toLowerCase(), Collections.singletonList("horst")); + when(subject.getPrincipal()).thenReturn("horst"); + User user = mock(User.class); + when(userService.load(matches("horst"))).thenReturn(user); + + // when... + filter.filter(containerRequest); + + // then ... + // ... succeeds + } + + @Test + public void shouldDenyWhenSubjectNameDoesntMatch() { + headers.put(USER_HEADER.toLowerCase(), Collections.singletonList("horst")); + when(subject.getPrincipal()).thenReturn("nothorst"); + + // expecting... + exceptionRule.expect(NotAuthorizedException.class); + + // when... + filter.filter(containerRequest); + } + + @Test + public void shouldAcceptWhenRolesMatch() throws NotFoundException { + // given... + headers.put(USER_HEADER.toLowerCase(), Collections.singletonList("horst")); + List roles = Arrays.asList("role1", "role2"); + headers.put(ROLE_HEADER.toLowerCase(), Collections.singletonList(String.join(",", roles))); + when(subject.getPrincipal()).thenReturn("horst"); + User user = mock(User.class); + when(userService.load(matches("horst"))).thenReturn(user); + mockRoles(roles, user); + + // when... + filter.filter(containerRequest); + // ... call a second time to see if caching of headers + filter.filter(containerRequest); + + // then ... + verify(userService, times(1)).load("horst"); + } + + @Test + public void shouldDenyWhenRolesNameDontMatch() throws NotFoundException { + // given... + headers.put(USER_HEADER.toLowerCase(), Collections.singletonList("horst")); + List roles = Arrays.asList("role1", "role2"); + headers.put(ROLE_HEADER.toLowerCase(), Collections.singletonList(String.join(",", roles.subList(0, 1)))); + when(subject.getPrincipal()).thenReturn("horst"); + User user = mock(User.class); + when(userService.load(matches("horst"))).thenReturn(user); + mockRoles(roles, user); + + // expecting... + exceptionRule.expect(NotAuthorizedException.class); + + // when... + filter.filter(containerRequest); + } + + private void mockRoles(List roles, User user) throws NotFoundException { + Set roleIds = new HashSet<>(); + when(user.getRoleIds()).thenReturn(roleIds); + for (String role : roles) { + String roleId = "idof_" + role; + roleIds.add(roleId); + when(roleService.exists(role)).thenReturn(true); + when(roleService.load(role)).thenAnswer(invocation -> { + Role r = mock(Role.class); + when(r.getId()).thenReturn(roleId); + return r; + }); + } + } + +} \ No newline at end of file