diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java new file mode 100644 index 0000000000..0723de3165 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java @@ -0,0 +1,39 @@ +/* + * 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 lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApiKey { + private final String name; + private final String role; + + ApiKey(ApiKeyWithPassphrase apiKeyWithPassphrase) { + this(apiKeyWithPassphrase.getName(), apiKeyWithPassphrase.getRole()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyCollection.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyCollection.java new file mode 100644 index 0000000000..79d0b98191 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyCollection.java @@ -0,0 +1,58 @@ +/* + * 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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.ArrayList; +import java.util.Collection; +import java.util.function.Predicate; + +import static java.util.stream.Collectors.toList; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@XmlAccessorType(XmlAccessType.FIELD) +class ApiKeyCollection { + private Collection keys; + + public ApiKeyCollection add(ApiKeyWithPassphrase key) { + Collection newKeys = new ArrayList<>(keys.size() + 1); + newKeys.addAll(keys); + newKeys.add(key); + return new ApiKeyCollection(newKeys); + } + + public ApiKeyCollection remove(Predicate predicate) { + Collection newKeys = keys.stream().filter(key -> !predicate.test(key)).collect(toList()); + return new ApiKeyCollection(newKeys); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java new file mode 100644 index 0000000000..d1808e515b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java @@ -0,0 +1,139 @@ +/* + * 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 com.google.common.util.concurrent.Striped; +import org.apache.shiro.authc.credential.PasswordService; +import org.apache.shiro.util.ThreadContext; +import sonia.scm.ContextEntry; +import sonia.scm.store.ConfigurationEntryStore; +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; + +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; +import static org.apache.commons.lang.RandomStringUtils.random; +import static sonia.scm.AlreadyExistsException.alreadyExists; + +class ApiKeyService { + + public static final int KEY_LENGTH = 20; + + private final ConfigurationEntryStore store; + private final PasswordService passwordService; + private final Supplier keyGenerator; + + private final Striped locks = Striped.readWriteLock(10); + + @Inject + ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService) { + this(storeFactory, passwordService, () -> random(KEY_LENGTH, 0, 0, true, true, null, new SecureRandom())); + } + + ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService, Supplier keyGenerator) { + this.store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build(); + this.passwordService = passwordService; + this.keyGenerator = keyGenerator; + } + + String createNewKey(String name, String role) { + String user = currentUser(); + String passphrase = keyGenerator.get(); + String hashedPassphrase = passwordService.encryptPassword(passphrase); + Lock lock = locks.get(user).writeLock(); + lock.lock(); + try { + if (containsName(user, name)) { + throw alreadyExists(ContextEntry.ContextBuilder.entity(ApiKeyWithPassphrase.class, name)); + } + final ApiKeyCollection apiKeyCollection = store.getOptional(user).orElse(new ApiKeyCollection(emptyList())); + final ApiKeyCollection newApiKeyCollection = apiKeyCollection.add(new ApiKeyWithPassphrase(name, role, hashedPassphrase)); + store.put(user, newApiKeyCollection); + } finally { + lock.unlock(); + } + return passphrase; + } + + void remove(String name) { + String user = currentUser(); + Lock lock = locks.get(user).writeLock(); + lock.lock(); + try { + if (!containsName(user, name)) { + return; + } + store.getOptional(user).ifPresent( + apiKeyCollection -> { + final ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(apiKeyWithPassphrase -> name.equals(apiKeyWithPassphrase.getName())); + store.put(user, newApiKeyCollection); + } + ); + } finally { + lock.unlock(); + } + } + + Optional check(String user, String keyName, String passphrase) { + Lock lock = locks.get(user).readLock(); + lock.lock(); + try { + return store + .get(user) + .getKeys() + .stream() + .filter(key -> key.getName().equals(keyName)) + .filter(key -> passwordService.passwordsMatch(passphrase, key.getPassphrase())) + .map(ApiKeyWithPassphrase::getRole) + .findAny(); + } finally { + lock.unlock(); + } + } + + Collection getKeys() { + return store.get(currentUser()).getKeys().stream().map(ApiKey::new).collect(toList()); + } + + private String currentUser() { + return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString(); + } + + private boolean containsName(String user, String name) { + return store + .getOptional(user) + .map(ApiKeyCollection::getKeys) + .orElse(emptyList()) + .stream() + .anyMatch(key -> key.getName().equals(name)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java new file mode 100644 index 0000000000..3b0d6de5fa --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java @@ -0,0 +1,43 @@ +/* + * 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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@XmlAccessorType(XmlAccessType.FIELD) +class ApiKeyWithPassphrase { + private String name; + private String role; + private String passphrase; +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java new file mode 100644 index 0000000000..82371932d7 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java @@ -0,0 +1,161 @@ +/* + * 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.credential.PasswordService; +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import sonia.scm.AlreadyExistsException; +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; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApiKeyServiceTest { + + int nextKey = 1; + + PasswordService passwordService = mock(PasswordService.class); + Supplier keyGenerator = () -> Integer.toString(nextKey++); + ConfigurationEntryStoreFactory storeFactory = new InMemoryConfigurationEntryStoreFactory(); + ConfigurationEntryStore store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build(); + ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator); + + + @BeforeEach + void mockPasswordService() { + when(passwordService.encryptPassword(any())) + .thenAnswer(invocationOnMock -> invocationOnMock.getArgument(0) + "-hashed"); + when(passwordService.passwordsMatch(any(), any())) + .thenAnswer(invocationOnMock -> invocationOnMock.getArgument(1, String.class).startsWith(invocationOnMock.getArgument(0))); + } + + @Nested + class WithLoggedInUser { + @BeforeEach + void mockUser() { + final Subject subject = mock(Subject.class); + ThreadContext.bind(subject); + final PrincipalCollection principalCollection = mock(PrincipalCollection.class); + when(subject.getPrincipals()).thenReturn(principalCollection); + when(principalCollection.getPrimaryPrincipal()).thenReturn("dent"); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldCreateNewKeyAndStoreItHashed() { + String newKey = service.createNewKey("1", "READ"); + + ApiKeyCollection apiKeys = store.get("dent"); + + assertThat(apiKeys.getKeys()).hasSize(1); + ApiKeyWithPassphrase key = apiKeys.getKeys().iterator().next(); + assertThat(key.getRole()).isEqualTo("READ"); + assertThat(key.getPassphrase()).isEqualTo("1-hashed"); + assertThat(newKey).isEqualTo("1"); + + Optional role = service.check("dent", "1", "1-hashed"); + + assertThat(role).contains("READ"); + } + + @Test + void shouldReturnRoleForKey() { + String newKey = service.createNewKey("1", "READ"); + + Optional role = service.check("dent", "1", newKey); + + assertThat(role).contains("READ"); + } + + @Test + void shouldNotReturnAnythingWithWrongKey() { + service.createNewKey("1", "READ"); + + Optional role = service.check("dent", "1", "wrong"); + + assertThat(role).isEmpty(); + } + + @Test + void shouldAddSecondKey() { + String firstKey = service.createNewKey("1", "READ"); + String secondKey = service.createNewKey("2", "WRITE"); + + ApiKeyCollection apiKeys = store.get("dent"); + + assertThat(apiKeys.getKeys()).hasSize(2); + + assertThat(service.check("dent", "1", firstKey)).contains("READ"); + assertThat(service.check("dent", "2", secondKey)).contains("WRITE"); + + assertThat(service.getKeys()).extracting("name").contains("1", "2"); + } + + @Test + void shouldRemoveKey() { + String firstKey = service.createNewKey("1", "READ"); + String secondKey = service.createNewKey("2", "WRITE"); + + service.remove("1"); + + assertThat(service.check("dent", "1", firstKey)).isEmpty(); + assertThat(service.check("dent", "2", secondKey)).contains("WRITE"); + } + + @Test + void shouldFailWhenAddingSameNameTwice() { + String firstKey = service.createNewKey("1", "READ"); + + assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE")); + + assertThat(service.check("dent", "1", firstKey)).contains("READ"); + } + + @Test + void shouldIgnoreCorrectPassphraseWithWrongName() { + String firstKey = service.createNewKey("1", "READ"); + + assertThat(service.check("dent", "other", firstKey)).isEmpty(); + } + } +}