diff --git a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java index 7309228edd..87ccfaf4cc 100644 --- a/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java +++ b/scm-core/src/main/java/sonia/scm/security/DAORealmHelper.java @@ -33,6 +33,7 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.base.Objects; import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet.Builder; @@ -128,7 +129,7 @@ public final class DAORealmHelper UsernamePasswordToken upt = (UsernamePasswordToken) token; String principal = upt.getUsername(); - return getAuthenticationInfo(principal, null); + return getAuthenticationInfo(principal, null, null); } /** @@ -137,10 +138,11 @@ public final class DAORealmHelper * * @param principal * @param credentials + * @param scope * * @return */ - public AuthenticationInfo getAuthenticationInfo(String principal, String credentials) { + public AuthenticationInfo getAuthenticationInfo(String principal, String credentials, Scope scope) { checkArgument(!Strings.isNullOrEmpty(principal), "username is required"); LOG.debug("try to authenticate {}", principal); @@ -159,6 +161,7 @@ public final class DAORealmHelper collection.add(principal, realm); collection.add(user, realm); collection.add(collectGroups(principal), realm); + collection.add(Objects.firstNonNull(scope, Scope.empty()), realm); String creds = credentials; diff --git a/scm-core/src/main/java/sonia/scm/security/Scope.java b/scm-core/src/main/java/sonia/scm/security/Scope.java new file mode 100644 index 0000000000..eeb7decf0b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/Scope.java @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ +package sonia.scm.security; + +import com.google.common.collect.ImmutableList; +import java.util.Collection; + +import java.util.Collections; +import java.util.Iterator; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * Scope of a token. A scope is able to reduce the permissions of a token authentication. That means we can issue a + * token which is only suitable for a single or a set of actions e.g.: we could create a token which can only read + * a single repository. The values of the scope should be explicit string representations of shiro permissions. An empty + * scope means all permissions of the user. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +@XmlRootElement(name = "scope") +@XmlAccessorType(XmlAccessType.FIELD) +public final class Scope implements Iterable { + + private static final Scope EMPTY = new Scope(Collections.emptySet()); + + private final Collection values; + + private Scope(Collection values) { + this.values = values; + } + + @Override + public Iterator iterator() { + return values.iterator(); + } + + /** + * Returns {@code true} if the scope is empty. + * + * @return {@code true} if the scope is empty + */ + public boolean isEmpty() { + return values.isEmpty(); + } + + /** + * Creates an empty scope. + * + * @return empty scope + */ + public static Scope empty(){ + return EMPTY; + } + + /** + * Creates a scope object from the given iterable. + * + * @param values values of scope + * + * @return new scope + */ + public static Scope valueOf(Iterable values) { + return new Scope(ImmutableList.copyOf(values)); + } + + /** + * Create a scope from the given array. + * + * @param values values of scope + * + * @return new scope. + */ + public static Scope valueOf(String... values) { + return new Scope(ImmutableList.copyOf(values)); + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder("["); + Iterator it = values.iterator(); + while (it.hasNext()) { + buffer.append('"').append(it.next()).append('"'); + if (it.hasNext()) { + buffer.append(", "); + } + } + return buffer.append("]").toString(); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AuthenticationResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AuthenticationResource.java index 8511d69767..7f77eb5ee8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AuthenticationResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AuthenticationResource.java @@ -39,6 +39,7 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.inject.Inject; import com.google.inject.Singleton; +import java.util.List; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.AuthenticationException; @@ -83,6 +84,7 @@ import javax.ws.rs.core.Response; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlRootElement; +import sonia.scm.security.Scope; /** * @@ -144,6 +146,7 @@ public class AuthenticationResource * @param username the username for the authentication * @param password the password for the authentication * @param cookie create authentication token + * @param scope scope of created token * * @return */ @@ -153,8 +156,9 @@ public class AuthenticationResource public Response authenticate(@Context HttpServletRequest request, @Context HttpServletResponse response, @FormParam("username") String username, - @FormParam("password") String password, @FormParam("rememberMe") - @QueryParam("cookie") boolean cookie) + @FormParam("password") String password, + @QueryParam("cookie") boolean cookie, + @QueryParam("scope") List scope) { Preconditions.checkArgument(!Strings.isNullOrEmpty(username), "username parameter is required"); @@ -171,7 +175,7 @@ public class AuthenticationResource User user = subject.getPrincipals().oneByType(User.class); - String token = tokenGenerator.createBearerToken(user); + String token = tokenGenerator.createBearerToken(user, scope != null ? Scope.valueOf(scope) : Scope.empty()); ScmState state; diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java index e0e8b4b8c9..41443e9cb5 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java @@ -50,6 +50,8 @@ import sonia.scm.plugin.Extension; import sonia.scm.user.UserDAO; import static com.google.common.base.Preconditions.checkArgument; + +import java.util.List; import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -113,8 +115,7 @@ public class BearerRealm extends AuthenticatingRealm * @return authentication data from user and group dao */ @Override - protected AuthenticationInfo doGetAuthenticationInfo( - AuthenticationToken token) + protected AuthenticationInfo doGetAuthenticationInfo( AuthenticationToken token) { checkArgument(token instanceof BearerAuthenticationToken, "%s is required", BearerAuthenticationToken.class); @@ -122,7 +123,7 @@ public class BearerRealm extends AuthenticatingRealm BearerAuthenticationToken bt = (BearerAuthenticationToken) token; Claims c = checkToken(bt); - return helper.getAuthenticationInfo(c.getSubject(), bt.getCredentials()); + return helper.getAuthenticationInfo(c.getSubject(), bt.getCredentials(), Scopes.fromClaims(c)); } /** diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerTokenGenerator.java b/scm-webapp/src/main/java/sonia/scm/security/BearerTokenGenerator.java index de50f0cc99..768bf7d236 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/BearerTokenGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/security/BearerTokenGenerator.java @@ -42,6 +42,7 @@ import org.slf4j.LoggerFactory; import sonia.scm.user.User; import static com.google.common.base.Preconditions.*; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; @@ -95,10 +96,11 @@ public final class BearerTokenGenerator * * * @param user user + * @param scope scope of token * * @return bearer token */ - public String createBearerToken(User user) { + public String createBearerToken(User user, Scope scope) { checkNotNull(user, "user is required"); String username = user.getName(); @@ -114,16 +116,19 @@ public final class BearerTokenGenerator // TODO: should be configurable long expiration = TimeUnit.MILLISECONDS.convert(10, TimeUnit.HOURS); - Map claim = Maps.newHashMap(); + Map claims = Maps.newHashMap(); + + // add scope to claims + Scopes.toClaims(claims, scope); // enrich claims with registered enrichers enrichers.forEach((enricher) -> { - enricher.enrich(claim); + enricher.enrich(claims); }); //J- return Jwts.builder() - .setClaims(claim) + .setClaims(claims) .setSubject(username) .setId(id) .signWith(SignatureAlgorithm.HS256, key.getBytes()) diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index e3826ad853..c86b6c325e 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -39,7 +39,6 @@ import com.github.legman.Subscribe; import com.google.common.base.Objects; import com.google.common.base.Preconditions; -import com.google.common.base.Predicate; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet.Builder; import com.google.inject.Inject; @@ -444,38 +443,8 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(roles); info.addStringPermissions(permissions); - - if (logger.isTraceEnabled()){ - logger.trace(createAuthorizationSummary(user, groups, info)); - } - return info; } - - private String createAuthorizationSummary(User user, GroupNames groups, AuthorizationInfo authzInfo) - { - StringBuilder buffer = new StringBuilder("authorization summary: "); - - buffer.append(SEPARATOR).append("username : ").append(user.getName()); - buffer.append(SEPARATOR).append("groups : "); - append(buffer, groups); - buffer.append(SEPARATOR).append("roles : "); - append(buffer, authzInfo.getRoles()); - buffer.append(SEPARATOR).append("permissions:"); - append(buffer, authzInfo.getStringPermissions()); - append(buffer, authzInfo.getObjectPermissions()); - - return buffer.toString(); - } - - private void append(StringBuilder buffer, Iterable iterable){ - if (iterable != null){ - for ( Object item : iterable ) - { - buffer.append(SEPARATOR).append(" - ").append(item); - } - } - } //~--- get methods ---------------------------------------------------------- diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java index 9981493f7d..0cc5db846c 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java @@ -45,15 +45,17 @@ import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; -import sonia.scm.group.GroupDAO; +import sonia.scm.group.GroupNames; import sonia.scm.plugin.Extension; -import sonia.scm.user.UserDAO; //~--- JDK imports ------------------------------------------------------------ import javax.inject.Inject; import javax.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Default authorizing realm. * @@ -64,6 +66,13 @@ import javax.inject.Singleton; @Singleton public class DefaultRealm extends AuthorizingRealm { + + private static final String SEPARATOR = System.getProperty("line.separator", "\n"); + + /** + * the logger for DefaultRealm + */ + private static final Logger LOG = LoggerFactory.getLogger(DefaultRealm.class); /** Field description */ @VisibleForTesting @@ -122,10 +131,61 @@ public class DefaultRealm extends AuthorizingRealm * @return */ @Override - protected AuthorizationInfo doGetAuthorizationInfo( - PrincipalCollection principals) + protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { - return collector.collect(principals); + AuthorizationInfo info = collector.collect(principals); + + Scope scope = principals.oneByType(Scope.class); + if (scope != null && ! scope.isEmpty()) { + LOG.trace("filter permissions by scope {}", scope); + AuthorizationInfo filtered = Scopes.filter(getPermissionResolver(), info, scope); + if (LOG.isTraceEnabled()) { + log(principals, info, filtered); + } + return filtered; + } else if (LOG.isTraceEnabled()) { + LOG.trace("principal does not contain scope informations, returning all permissions"); + log(principals, info, null); + } + + return info; + } + + private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) { + StringBuilder buffer = new StringBuilder("authorization summary: "); + + buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal()); + buffer.append(SEPARATOR).append("groups : "); + append(buffer, collection.oneByType(GroupNames.class)); + buffer.append(SEPARATOR).append("roles : "); + append(buffer, original.getRoles()); + buffer.append(SEPARATOR).append("scope : "); + append(buffer, collection.oneByType(Scope.class)); + + if ( filtered != null ) { + buffer.append(SEPARATOR).append("permissions (filtered by scope): "); + append(buffer, filtered); + buffer.append(SEPARATOR).append("permissions (unfiltered): "); + } else { + buffer.append(SEPARATOR).append("permissions: "); + } + append(buffer, original); + + LOG.trace(buffer.toString()); + } + + private void append(StringBuilder buffer, AuthorizationInfo authz) { + append(buffer, authz.getStringPermissions()); + append(buffer, authz.getObjectPermissions()); + } + + private void append(StringBuilder buffer, Iterable iterable){ + if (iterable != null){ + for ( Object item : iterable ) + { + buffer.append(SEPARATOR).append(" - ").append(item); + } + } } //~--- fields --------------------------------------------------------------- diff --git a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java new file mode 100644 index 0000000000..67c2e6cc4c --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ +package sonia.scm.security; + +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.authz.Permission; +import org.apache.shiro.authz.SimpleAuthorizationInfo; +import org.apache.shiro.authz.permission.PermissionResolver; + +/** + * Utile methods for {@link Scope}. + * + * @author Sebastian Sdorra + * @since 2.0.0 + */ +public final class Scopes { + + /** Key of scope in the claims of a token **/ + public final static String CLAIMS_KEY = "scope"; + + private Scopes() { + } + + /** + * Returns scope from a token claims. If the claims does not contain a scope object, the method will return an empty + * scope. + * + * @param claims token claims + * + * @return scope of claims + */ + @SuppressWarnings("unchecked") + public static Scope fromClaims(Map claims) { + Scope scope = Scope.empty(); + if (claims.containsKey(Scopes.CLAIMS_KEY)) { + scope = Scope.valueOf((List)claims.get(Scopes.CLAIMS_KEY)); + } + return scope; + } + + /** + * Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not + * empty. + * + * @param claims token claims + * @param scope scope + */ + public static void toClaims(Map claims, Scope scope) { + if (scope != null && ! scope.isEmpty()) { + claims.put(CLAIMS_KEY, ImmutableSet.copyOf(scope)); + } + } + + /** + * Filter permissions from {@link AuthorizationInfo} by scope values. Only permission definitions from the scope will + * be returned and only if a permission from the {@link AuthorizationInfo} implies the requested scope permission. + * + * @param resolver permission resolver + * @param authz authorization info + * @param scope scope + * + * @return filtered {@link AuthorizationInfo} + */ + public static AuthorizationInfo filter(PermissionResolver resolver, AuthorizationInfo authz, Scope scope) { + List authzPermissions = authzPermissions(resolver, authz); + Predicate predicate = implies(authzPermissions); + Set filteredPermissions = resolve(resolver, ImmutableList.copyOf(scope)) + .stream() + .filter(predicate) + .collect(Collectors.toSet()); + + Set roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles())); + SimpleAuthorizationInfo authzFiltered = new SimpleAuthorizationInfo(roles); + authzFiltered.setObjectPermissions(filteredPermissions); + return authzFiltered; + } + + private static Collection nullToEmpty(Collection collection) { + return collection != null ? collection : Collections.emptySet(); + } + + private static Collection resolve(PermissionResolver resolver, Collection permissions) { + return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission); + } + + private static Predicate implies(Iterable authzPermissions){ + return (scopePermission) -> { + for ( Permission authzPermission : authzPermissions ) { + if (authzPermission.implies(scopePermission)) { + return true; + } + } + return false; + }; + } + + private static List authzPermissions(PermissionResolver resolver, AuthorizationInfo authz){ + List authzPermissions = Lists.newArrayList(); + authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions())); + authzPermissions.addAll(resolve(resolver, authz.getStringPermissions())); + return authzPermissions; + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/it/AuthorizationScopeITCase.java b/scm-webapp/src/test/java/sonia/scm/it/AuthorizationScopeITCase.java new file mode 100644 index 0000000000..f5a4afd7c2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/it/AuthorizationScopeITCase.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ +package sonia.scm.it; + +import com.google.common.base.Strings; +import com.sun.jersey.api.client.Client; +import com.sun.jersey.api.client.ClientResponse; +import com.sun.jersey.api.client.GenericType; +import com.sun.jersey.api.client.WebResource; +import com.sun.jersey.core.util.MultivaluedMapImpl; +import java.util.List; +import javax.ws.rs.core.MultivaluedMap; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import sonia.scm.ScmState; +import static sonia.scm.it.IntegrationTestUtil.*; +import static sonia.scm.it.RepositoryITUtil.*; +import static sonia.scm.it.RepositoryITUtil.createRepository; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryTestData; + +/** + * Integration test for authorization with scope. + * + * @author Sebastian Sdorra + */ +public class AuthorizationScopeITCase { + + private Repository heartOfGold; + private Repository puzzle42; + + /** + * Create test repositories. + */ + @Before + public void createTestRepositories(){ + Client adminClient = createAdminClient(); + this.heartOfGold = createRepository(adminClient, RepositoryTestData.createHeartOfGold("git")); + this.puzzle42 = createRepository(adminClient, RepositoryTestData.create42Puzzle("git")); + } + + /** + * Delete test repositories. + */ + @After + public void deleteTestRepositories(){ + Client adminClient = createAdminClient(); + deleteRepository(adminClient, heartOfGold.getId()); + deleteRepository(adminClient, puzzle42.getId()); + } + + /** + * Read all available repositories without scope. + */ + @Test + public void testAuthenticateWithoutScope() { + Assert.assertEquals(2, getRepositories(createAuthenticationToken()).size()); + } + + /** + * Read all available repositories with a scope for only one of them. + */ + @Test + public void testAuthenticateWithScope() { + String scope = "repository:read:".concat(heartOfGold.getId()); + Assert.assertEquals(1, getRepositories(createAuthenticationToken(scope)).size()); + } + + private List getRepositories(String token) { + Client client = createClient(); + WebResource wr = client.resource(createResourceUrl("repositories")); + return wr.header("Authorization", "Bearer ".concat(token)).get(new GenericType>(){}); + } + + private String createAuthenticationToken() { + return createAuthenticationToken(""); + } + + private String createAuthenticationToken(String scope) { + Client client = createClient(); + String url = createResourceUrl("authentication/login"); + if (!Strings.isNullOrEmpty(scope)) { + url = url.concat("?scope=").concat(scope); + } + WebResource wr = client.resource(url); + MultivaluedMap formData = new MultivaluedMapImpl(); + + formData.add("username", ADMIN_USERNAME); + formData.add("password", ADMIN_PASSWORD); + + ClientResponse response = wr.type("application/x-www-form-urlencoded").post(ClientResponse.class, formData); + if (response.getStatus() >= 300 ){ + Assert.fail("authentication failed with status code " + response.getStatus()); + } + + return response.getEntity(ScmState.class).getToken(); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java index e873e72f79..3285d2d1a1 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java @@ -35,6 +35,7 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.collect.ImmutableList; import com.google.common.collect.Sets; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwsHeader; @@ -115,6 +116,35 @@ public class BearerRealmTest assertEquals(marvin.getName(), principals.getPrimaryPrincipal()); assertEquals(marvin, principals.oneByType(User.class)); + assertNotNull(principals.oneByType(Scope.class)); + assertTrue(principals.oneByType(Scope.class).isEmpty()); + } + + /** + * Test {@link BearerRealm#doGetAuthenticationInfo(AuthenticationToken)} with scope. + * + */ + @Test + public void testDoGetAuthenticationInfoWithScope() + { + SecureKey key = createSecureKey(); + + User marvin = UserTestData.createMarvin(); + + when(userDAO.get(marvin.getName())).thenReturn(marvin); + + resolveKey(key); + + String compact = createCompactToken( + marvin.getName(), + key, + new Date(System.currentTimeMillis() + 60000), + Scope.valueOf("repo:*", "user:*") + ); + + AuthenticationInfo info = realm.doGetAuthenticationInfo(new BearerAuthenticationToken(compact)); + Scope scope = info.getPrincipals().oneByType(Scope.class); + assertThat(scope, Matchers.containsInAnyOrder("repo:*", "user:*")); } /** @@ -159,7 +189,7 @@ public class BearerRealmTest resolveKey(key); Date exp = new Date(System.currentTimeMillis() - 600l); - String compact = createCompactToken(trillian.getName(), key, exp); + String compact = createCompactToken(trillian.getName(), key, exp, Scope.empty()); realm.doGetAuthenticationInfo(new BearerAuthenticationToken(compact)); } @@ -221,66 +251,30 @@ public class BearerRealmTest //~--- methods -------------------------------------------------------------- - /** - * Method description - * - * - * @param subject - * @param key - * - * @return - */ - private String createCompactToken(String subject, SecureKey key) - { - return createCompactToken(subject, key, - new Date(System.currentTimeMillis() + 60000)); +private String createCompactToken(String subject, SecureKey key) { + return createCompactToken(subject, key, Scope.empty()); + } + + private String createCompactToken(String subject, SecureKey key, Scope scope) { + return createCompactToken(subject, key, new Date(System.currentTimeMillis() + 60000), scope); } - /** - * Method description - * - * - * @param subject - * @param key - * @param exp - * - * @return - */ - private String createCompactToken(String subject, SecureKey key, Date exp) - { - //J- + private String createCompactToken(String subject, SecureKey key, Date exp, Scope scope) { return Jwts.builder() + .claim(Scopes.CLAIMS_KEY, ImmutableList.copyOf(scope)) .setSubject(subject) .setExpiration(exp) .signWith(SignatureAlgorithm.HS256, key.getBytes()) .compact(); - //J+ } - /** - * Method description - * - * - * @return - */ - private SecureKey createSecureKey() - { + private SecureKey createSecureKey() { byte[] bytes = new byte[32]; - random.nextBytes(bytes); - return new SecureKey(bytes, System.currentTimeMillis()); } - /** - * Method description - * - * - * @param key - */ - private void resolveKey(SecureKey key) - { - //J- + private void resolveKey(SecureKey key) { when( keyResolver.resolveSigningKey( any(JwsHeader.class), @@ -293,7 +287,6 @@ public class BearerRealmTest SignatureAlgorithm.HS256.getValue() ) ); - //J+ } //~--- fields --------------------------------------------------------------- diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerTokenGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerTokenGeneratorTest.java index 4661d71eac..688edc8eb1 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/BearerTokenGeneratorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/BearerTokenGeneratorTest.java @@ -61,83 +61,82 @@ import java.security.SecureRandom; import java.util.Set; /** - * + * Tests {@link BearerTokenGenerator}. + * * @author Sebastian Sdorra */ @RunWith(MockitoJUnitRunner.class) public class BearerTokenGeneratorTest { + private final SecureRandom random = new SecureRandom(); + + @Mock + private KeyGenerator keyGenerator; + + @Mock + private SecureKeyResolver keyResolver; + + private BearerTokenGenerator tokenGenerator; + /** - * Method description - * - */ - @Test - public void testCreateBearerToken() - { - User trillian = UserTestData.createTrillian(); - SecureKey key = createSecureKey(); - - when(keyGenerator.createKey()).thenReturn("sid"); - when(keyResolver.getSecureKey(trillian.getName())).thenReturn(key); - - String token = tokenGenerator.createBearerToken(trillian); - - assertThat(token, not(isEmptyOrNullString())); - assertTrue(Jwts.parser().isSigned(token)); - - Claims claims = Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws( - token).getBody(); - - assertEquals(trillian.getName(), claims.getSubject()); - assertEquals("sid", claims.getId()); - assertEquals("123", claims.get("abc")); - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * + * Set up mocks and object under test. */ @Before - public void setUp() - { + public void setUp() { Set enrichers = Sets.newHashSet(); enrichers.add((claims) -> {claims.put("abc", "123");}); tokenGenerator = new BearerTokenGenerator(keyGenerator, keyResolver, enrichers); } - //~--- methods -------------------------------------------------------------- - + /** - * Method description - * - * - * @return + * Tests {@link BearerTokenGenerator#createBearerToken(User, Scope)}. */ - private SecureKey createSecureKey() + @Test + public void testCreateBearerToken() { - byte[] bytes = new byte[32]; - - random.nextBytes(bytes); - - return new SecureKey(bytes, System.currentTimeMillis()); + Claims claims = createAssertAndParseToken(UserTestData.createTrillian(), "sid", Scope.empty()); + + assertEquals("123", claims.get("abc")); + assertNull(claims.get(Scopes.CLAIMS_KEY)); } - //~--- fields --------------------------------------------------------------- + /** + * Tests {@link BearerTokenGenerator#createBearerToken(User, Scope)} with scope. + */ + @Test + @SuppressWarnings("unchecked") + public void testCreateBearerTokenWithScope(){ + Claims claims = createAssertAndParseToken(UserTestData.createTrillian(), "sid", Scope.valueOf("repo:*", "user:*")); + assertEquals("123", claims.get("abc")); + + Scope scope = Scopes.fromClaims(claims); + assertThat(scope, containsInAnyOrder("repo:*", "user:*")); + } + + private Claims createAssertAndParseToken(User user, String id, Scope scope){ + SecureKey key = createSecureKey(); - /** Field description */ - private final SecureRandom random = new SecureRandom(); + when(keyGenerator.createKey()).thenReturn(id); + when(keyResolver.getSecureKey(user.getName())).thenReturn(key); - /** Field description */ - @Mock - private KeyGenerator keyGenerator; + String token = tokenGenerator.createBearerToken(user, scope); - /** Field description */ - @Mock - private SecureKeyResolver keyResolver; + assertThat(token, not(isEmptyOrNullString())); + assertTrue(Jwts.parser().isSigned(token)); - /** Field description */ - private BearerTokenGenerator tokenGenerator; + Claims claims = Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(token).getBody(); + + assertEquals(user.getName(), claims.getSubject()); + assertEquals(id, claims.getId()); + + return claims; + } + + private SecureKey createSecureKey() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return new SecureKey(bytes, System.currentTimeMillis()); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java index dfbc13e428..baa3de2f56 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultRealmTest.java @@ -35,6 +35,7 @@ package sonia.scm.security; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.collect.Collections2; import com.google.common.collect.Lists; import org.apache.shiro.authc.AuthenticationInfo; @@ -71,6 +72,11 @@ import static org.mockito.Mockito.*; //~--- JDK imports ------------------------------------------------------------ import java.util.List; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.authz.Permission; +import org.apache.shiro.authz.SimpleAuthorizationInfo; +import org.apache.shiro.authz.permission.WildcardPermissionResolver; +import org.hamcrest.Matchers; import org.mockito.InjectMocks; /** @@ -109,6 +115,62 @@ public class DefaultRealmTest realm.doGetAuthorizationInfo(col); verify(collector, times(1)).collect(col); } + + /** + * Tests {@link DefaultRealm#doGetAuthorizationInfo(PrincipalCollection)} without scope. + */ + @Test + public void testGetAuthorizationInfoWithoutScope(){ + SimplePrincipalCollection col = new SimplePrincipalCollection(); + + SimpleAuthorizationInfo collectorsAuthz = new SimpleAuthorizationInfo(); + collectorsAuthz.addStringPermission("repository:*"); + when(collector.collect(col)).thenReturn(collectorsAuthz); + + AuthorizationInfo realmsAutz = realm.doGetAuthorizationInfo(col); + assertThat(realmsAutz.getObjectPermissions(), is(nullValue())); + assertThat(realmsAutz.getStringPermissions(), Matchers.contains("repository:*")); + } + + /** + * Tests {@link DefaultRealm#doGetAuthorizationInfo(PrincipalCollection)} with empty scope. + */ + @Test + public void testGetAuthorizationInfoWithEmptyScope(){ + SimplePrincipalCollection col = new SimplePrincipalCollection(); + col.add(Scope.empty(), DefaultRealm.REALM); + + SimpleAuthorizationInfo collectorsAuthz = new SimpleAuthorizationInfo(); + collectorsAuthz.addStringPermission("repository:*"); + when(collector.collect(col)).thenReturn(collectorsAuthz); + + AuthorizationInfo realmsAutz = realm.doGetAuthorizationInfo(col); + assertThat(realmsAutz.getObjectPermissions(), is(nullValue())); + assertThat(realmsAutz.getStringPermissions(), Matchers.contains("repository:*")); + } + + /** + * Tests {@link DefaultRealm#doGetAuthorizationInfo(PrincipalCollection)} with scope. + */ + @Test + public void testGetAuthorizationInfoWithScope(){ + SimplePrincipalCollection col = new SimplePrincipalCollection(); + col.add(Scope.valueOf("user:*:me"), DefaultRealm.REALM); + + SimpleAuthorizationInfo collectorsAuthz = new SimpleAuthorizationInfo(); + collectorsAuthz.addStringPermission("repository:*"); + collectorsAuthz.addStringPermission("user:*:me"); + when(collector.collect(col)).thenReturn(collectorsAuthz); + + AuthorizationInfo realmsAutz = realm.doGetAuthorizationInfo(col); + assertThat( + Collections2.transform(realmsAutz.getObjectPermissions(), Permission::toString), + allOf( + Matchers.contains("user:*:me"), + not(Matchers.contains("repository:*")) + ) + ); + } /** * Method description @@ -223,6 +285,9 @@ public class DefaultRealmTest hashService.setHashIterations(512); service.setHashService(hashService); realm = new DefaultRealm(service, collector, helperFactory); + + // set permission resolver + realm.setPermissionResolver(new WildcardPermissionResolver()); } //~--- methods -------------------------------------------------------------- diff --git a/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java b/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java new file mode 100644 index 0000000000..05b9c5cb92 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2014, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + +package sonia.scm.security; + +import com.google.common.collect.Collections2; +import com.google.common.collect.Sets; +import java.util.Set; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.authz.Permission; +import org.apache.shiro.authz.SimpleAuthorizationInfo; +import org.apache.shiro.authz.permission.WildcardPermission; +import org.apache.shiro.authz.permission.WildcardPermissionResolver; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.hamcrest.Matchers.*; + +/** + * Unit tests for {@link Scopes}. + * + * @author Sebastian Sdorra + */ +public class ScopesTest { + + private final WildcardPermissionResolver resolver = new WildcardPermissionResolver(); + + /** + * Tests that filter keep roles. + */ + @Test + public void testFilterKeepRoles(){ + AuthorizationInfo authz = authz("repository:read:123"); + + AuthorizationInfo filtered = Scopes.filter(resolver, authz, Scope.empty()); + assertThat(filtered.getRoles(), containsInAnyOrder("unit", "test")); + } + + /** + * Tests filter with a simple allow. + */ + @Test + public void testFilterSimpleAllow() { + Scope scope = Scope.valueOf("repository:read:123"); + AuthorizationInfo authz = authz("repository:*", "user:*:me"); + + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read:123"); + } + + /** + * Tests filter with a simple deny. + */ + @Test + public void testFilterSimpleDeny() { + Scope scope = Scope.valueOf("repository:read:123"); + AuthorizationInfo authz = authz("user:*:me"); + + AuthorizationInfo filtered = Scopes.filter(resolver, authz, scope); + assertThat(filtered.getStringPermissions(), is(nullValue())); + assertThat(filtered.getObjectPermissions(), is(emptyCollectionOf(Permission.class))); + } + + /** + * Tests filter with a multiple scope entries. + */ + @Test + public void testFilterMultiple() { + Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4"); + AuthorizationInfo authz = authz("repo:read:*"); + + assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:2"); + } + + /** + * Tests filter with admin permissions. + */ + @Test + public void testFilterAdmin(){ + Scope scope = Scope.valueOf("repository:*", "user:*:me"); + AuthorizationInfo authz = authz("*"); + + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:*", "user:*:me"); + } + + /** + * Tests filter with requested admin permissions from a non admin. + */ + @Test + public void testFilterRequestAdmin(){ + Scope scope = Scope.valueOf("*"); + AuthorizationInfo authz = authz("repository:*"); + + assertThat( + Scopes.filter(resolver, authz, scope).getObjectPermissions(), + is(emptyCollectionOf(Permission.class)) + ); + } + + private void assertPermissions(AuthorizationInfo authz, Object... permissions) { + assertThat(authz.getStringPermissions(), is(nullValue())); + assertThat( + Collections2.transform(authz.getObjectPermissions(), Permission::toString), + containsInAnyOrder(permissions) + ); + } + + private AuthorizationInfo authz( String... values ) { + SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test")); + Set permissions = Sets.newLinkedHashSet(); + for ( String value : values ) { + permissions.add(new WildcardPermission(value)); + } + info.setObjectPermissions(permissions); + return info; + } + +} \ No newline at end of file