diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java new file mode 100644 index 0000000000..4cbe9abdcc --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java @@ -0,0 +1,67 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security; + +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; +import org.apache.shiro.realm.AuthenticatingRealm; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.util.Optional; + +import static com.google.common.base.Preconditions.checkArgument; + +@Singleton +@Extension +public class ApiKeyRealm extends AuthenticatingRealm { + + private final ApiKeyService apiKeyService; + private final DAORealmHelper helper; + + @Inject + public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory) { + this.apiKeyService = apiKeyService; + this.helper = helperFactory.create("ApiTokenRealm"); + setAuthenticationTokenClass(BearerToken.class); + setCredentialsMatcher(new AllowAllCredentialsMatcher()); + } + + @Override + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { + checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class); + BearerToken bt = (BearerToken) token; + ApiKeyService.CheckResult check = apiKeyService.check(bt.getCredentials()); + return helper + .authenticationInfoBuilder(check.getUser()) + .withSessionId(bt.getPrincipal()) +// .withScope() + .build(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java index b7489c0e38..ada0cd46e6 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java @@ -37,7 +37,6 @@ import sonia.scm.store.ConfigurationEntryStoreFactory; import javax.inject.Inject; import java.security.SecureRandom; import java.util.Collection; -import java.util.Optional; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.function.Supplier; @@ -114,16 +113,16 @@ public class ApiKeyService { } } - Optional check(String tokenAsString) { + CheckResult check(String tokenAsString) { return check(tokenHandler.readToken(tokenAsString) .orElseThrow(AuthorizationException::new)); } - private Optional check(ApiKeyTokenHandler.Token token) { + private CheckResult check(ApiKeyTokenHandler.Token token) { return check(token.getUser(), token.getApiKeyId(), token.getPassphrase()); } - Optional check(String user, String id, String passphrase) { + CheckResult check(String user, String id, String passphrase) { Lock lock = locks.get(user).readLock(); lock.lock(); try { @@ -134,7 +133,9 @@ public class ApiKeyService { .filter(key -> key.getId().equals(id)) .filter(key -> passwordService.passwordsMatch(passphrase, key.getPassphrase())) .map(ApiKeyWithPassphrase::getRole) - .findAny(); + .map(role -> new CheckResult(user, role)) + .findAny() + .orElseThrow(AuthorizationException::new); } finally { lock.unlock(); } @@ -177,4 +178,11 @@ public class ApiKeyService { private final String token; private final String id; } + + @Getter + @AllArgsConstructor + public static class CheckResult { + private final String user; + private final String role; + } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java index ebd29788d9..2af6b9faea 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java @@ -25,6 +25,7 @@ package sonia.scm.security; import org.apache.shiro.authc.credential.PasswordService; +import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; @@ -37,7 +38,6 @@ import sonia.scm.store.ConfigurationEntryStore; import sonia.scm.store.ConfigurationEntryStoreFactory; import sonia.scm.store.InMemoryConfigurationEntryStoreFactory; -import java.util.Optional; import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; @@ -94,18 +94,18 @@ class ApiKeyServiceTest { assertThat(key.getRole()).isEqualTo("READ"); assertThat(key.getPassphrase()).isEqualTo("1-hashed"); - Optional role = service.check("dent", "1", "1-hashed"); + ApiKeyService.CheckResult role = service.check("dent", "1", "1-hashed"); - assertThat(role).contains("READ"); + assertThat(role).extracting("role").isEqualTo("READ"); } @Test void shouldReturnRoleForKey() { String newKey = service.createNewKey("1", "READ").getToken(); - Optional role = service.check(newKey); + ApiKeyService.CheckResult role = service.check(newKey); - assertThat(role).contains("READ"); + assertThat(role).extracting("role").isEqualTo("READ"); } @Test @@ -117,9 +117,7 @@ class ApiKeyServiceTest { void shouldNotReturnAnythingWithWrongKey() { service.createNewKey("1", "READ"); - Optional role = service.check("dent", "1", "wrong"); - - assertThat(role).isEmpty(); + assertThrows(AuthorizationException.class, () -> service.check("dent", "1", "wrong")); } @Test @@ -131,8 +129,8 @@ class ApiKeyServiceTest { assertThat(apiKeys.getKeys()).hasSize(2); - assertThat(service.check(firstKey.getToken())).contains("READ"); - assertThat(service.check(secondKey.getToken())).contains("WRITE"); + assertThat(service.check(firstKey.getToken())).extracting("role").isEqualTo("READ"); + assertThat(service.check(secondKey.getToken())).extracting("role").isEqualTo("WRITE"); assertThat(service.getKeys()).extracting("id") .contains(firstKey.getId(), secondKey.getId()); @@ -145,8 +143,8 @@ class ApiKeyServiceTest { service.remove("1"); - assertThat(service.check(firstKey)).isEmpty(); - assertThat(service.check(secondKey)).contains("WRITE"); + assertThrows(AuthorizationException.class, () -> service.check(firstKey)); + assertThat(service.check(secondKey)).extracting("role").isEqualTo("WRITE"); } @Test @@ -155,14 +153,14 @@ class ApiKeyServiceTest { assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE")); - assertThat(service.check(firstKey)).contains("READ"); + assertThat(service.check(firstKey)).extracting("role").isEqualTo("READ"); } @Test void shouldIgnoreCorrectPassphraseWithWrongName() { String firstKey = service.createNewKey("1", "READ").getToken(); - assertThat(service.check("dent", "other", firstKey)).isEmpty(); + assertThrows(AuthorizationException.class, () -> service.check("dent", "other", firstKey)); } } }