diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java index 1b0a3b2388..3379041100 100644 --- a/scm-core/src/main/java/sonia/scm/user/User.java +++ b/scm-core/src/main/java/sonia/scm/user/User.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.user; //~--- non-JDK imports -------------------------------------------------------- @@ -50,7 +50,7 @@ import java.security.Principal; @StaticPermissions( value = "user", globalPermissions = {"create", "list", "autocomplete"}, - permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys"}, + permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"}, custom = true, customGlobal = true ) @XmlRootElement(name = "users") diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 6112d233f4..b3dbcb5b6e 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -85,6 +85,9 @@ public class VndMediaType { public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX; public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX; + public static final String API_KEY = PREFIX + "apiKey" + SUFFIX; + public static final String API_KEY_COLLECTION = PREFIX + "apiKeyCollection" + SUFFIX; + private VndMediaType() { } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyCollectionToDtoMapper.java new file mode 100644 index 0000000000..17d8094380 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyCollectionToDtoMapper.java @@ -0,0 +1,55 @@ +/* + * 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.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import sonia.scm.security.ApiKey; + +import javax.inject.Inject; +import java.util.Collection; +import java.util.List; + +import static java.util.stream.Collectors.toList; + +public class ApiKeyCollectionToDtoMapper { + + private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper; + private final ResourceLinks resourceLinks; + + @Inject + public ApiKeyCollectionToDtoMapper(ApiKeyToApiKeyDtoMapper apiKeyDtoMapper, ResourceLinks resourceLinks) { + this.apiKeyDtoMapper = apiKeyDtoMapper; + this.resourceLinks = resourceLinks; + } + + public HalRepresentation map(Collection keys) { + List dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList()); + final Links.Builder links = Links.linkingTo(); + links.self(resourceLinks.apiKeyCollection().self()); + return new HalRepresentation(links.build(), Embedded.embedded("apiKeys", dtos)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyDto.java new file mode 100644 index 0000000000..c9a40c26e1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyDto.java @@ -0,0 +1,41 @@ +/* + * 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.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ApiKeyDto extends HalRepresentation { + private String displayName; + private String role; + + public ApiKeyDto(Links links) { + super(links); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyResource.java new file mode 100644 index 0000000000..3d8ac580f8 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyResource.java @@ -0,0 +1,117 @@ +/* + * 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.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import sonia.scm.ContextEntry; +import sonia.scm.security.ApiKey; +import sonia.scm.security.ApiKeyService; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; + +import static sonia.scm.NotFoundException.notFound; + +public class ApiKeyResource { + + private final ApiKeyService apiKeyService; + private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper; + private final ApiKeyToApiKeyDtoMapper apiKeyMapper; + + @Inject + public ApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper) { + this.apiKeyService = apiKeyService; + this.apiKeyCollectionMapper = apiKeyCollectionMapper; + this.apiKeyMapper = apiKeyMapper; + } + + @GET + @Path("") + @Produces(VndMediaType.API_KEY_COLLECTION) + @Operation(summary = "Get the api keys for the current user", description = "Returns the registered api keys for the logged in user.", tags = "User") + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.API_KEY_COLLECTION, + schema = @Schema(implementation = HalRepresentation.class) + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public HalRepresentation getForCurrentUser() { + return apiKeyCollectionMapper.map(apiKeyService.getKeys()); + } + + @GET + @Path("{id}") + @Produces(VndMediaType.API_KEY_COLLECTION) + @Operation(summary = "Get one api key for the current user", description = "Returns the registered api key with the given id for the logged in user.", tags = "User") + @ApiResponse( + responseCode = "200", + description = "success", + content = @Content( + mediaType = VndMediaType.API_KEY_COLLECTION, + schema = @Schema(implementation = HalRepresentation.class) + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "404", + description = "not found, no api key with the given id for the current user available", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public ApiKeyDto get(@PathParam("id") String id) { + return apiKeyService + .getKeys() + .stream() + .filter(key -> key.getId().equals(id)) + .map(apiKeyMapper::map).findAny() + .orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id))); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyToApiKeyDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyToApiKeyDtoMapper.java new file mode 100644 index 0000000000..04935149f9 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyToApiKeyDtoMapper.java @@ -0,0 +1,47 @@ +/* + * 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.api.v2.resources; + +import de.otto.edison.hal.Links; +import org.mapstruct.Mapper; +import org.mapstruct.ObjectFactory; +import sonia.scm.security.ApiKey; + +import javax.inject.Inject; + +@Mapper +public abstract class ApiKeyToApiKeyDtoMapper { + + @Inject + private ResourceLinks links; + + abstract ApiKeyDto map(ApiKey key); + + @ObjectFactory + ApiKeyDto createDto(ApiKey key) { + Links.linkingTo().self(links.apiKey().self(key.getId())); + return new ApiKeyDto(Links.linkingTo().self(links.apiKey().self(key.getId())).build()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index eb54d90684..f4638ef3d9 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -86,5 +86,7 @@ public class MapperModule extends AbstractModule { bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST); bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class)); + + bind(ApiKeyToApiKeyDtoMapper.class).to(Mappers.getMapperClass(ApiKeyToApiKeyDtoMapper.class)); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java index 705573e3c3..2101ba2ffb 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java @@ -94,6 +94,9 @@ public class MeDtoFactory extends HalAppenderMapper { if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) { linksBuilder.single(link("password", resourceLinks.me().passwordChange())); } + if (UserPermissions.changeApiKeys(user).isPermitted()) { + linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self())); + } Embedded.Builder embeddedBuilder = embeddedBuilder(); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java index 9393b6c9d0..1e7c89f908 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import io.swagger.v3.oas.annotations.OpenAPIDefinition; @@ -35,6 +35,7 @@ import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.inject.Provider; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.GET; @@ -61,11 +62,14 @@ public class MeResource { private final UserManager userManager; private final PasswordService passwordService; + private final Provider apiKeyResource; + @Inject - public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) { + public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider apiKeyResource) { this.meDtoFactory = meDtoFactory; this.userManager = userManager; this.passwordService = passwordService; + this.apiKeyResource = apiKeyResource; } /** @@ -118,4 +122,9 @@ public class MeResource { ); return Response.noContent().build(); } + + @Path("apiKeys") + public ApiKeyResource apiKeys() { + return apiKeyResource.get(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 546b6c8b52..945d62a73b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -204,6 +204,38 @@ class ResourceLinks { } } + public ApiKeyCollectionLinks apiKeyCollection() { + return new ApiKeyCollectionLinks(scmPathInfoStore.get()); + } + + static class ApiKeyCollectionLinks { + private final LinkBuilder collectionLinkBuilder; + + ApiKeyCollectionLinks(ScmPathInfo pathInfo) { + this.collectionLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class); + } + + String self() { + return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href(); + } + } + + public ApiKeyLinks apiKey() { + return new ApiKeyLinks(scmPathInfoStore.get()); + } + + static class ApiKeyLinks { + private final LinkBuilder apiKeyLinkBuilder; + + ApiKeyLinks(ScmPathInfo pathInfo) { + this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class); + } + + String self(String id) { + return apiKeyLinkBuilder.method("apiKeys").parameters().method("get").parameters(id).href(); + } + } + UserCollectionLinks userCollection() { return new UserCollectionLinks(scmPathInfoStore.get()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java index fa6ff90615..ba5e027bf4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import io.swagger.v3.oas.annotations.Operation; @@ -131,7 +131,7 @@ public class UserResource { * * Note: This method requires "user" privilege. * - * @param name name of the user to be modified + * @param name name of the user to be modified * @param user user object to modify */ @PUT diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java index 0723de3165..cb67f1c8b8 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java @@ -30,10 +30,11 @@ import lombok.Getter; @Getter @AllArgsConstructor public class ApiKey { - private final String name; + private final String id; + private final String displayName; private final String role; ApiKey(ApiKeyWithPassphrase apiKeyWithPassphrase) { - this(apiKeyWithPassphrase.getName(), apiKeyWithPassphrase.getRole()); + this(apiKeyWithPassphrase.getId(), apiKeyWithPassphrase.getDisplayName(), apiKeyWithPassphrase.getRole()); } } 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 d1808e515b..097ad98e51 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java @@ -44,30 +44,32 @@ import static java.util.stream.Collectors.toList; import static org.apache.commons.lang.RandomStringUtils.random; import static sonia.scm.AlreadyExistsException.alreadyExists; -class ApiKeyService { +public class ApiKeyService { public static final int KEY_LENGTH = 20; private final ConfigurationEntryStore store; private final PasswordService passwordService; - private final Supplier keyGenerator; + private final KeyGenerator keyGenerator; + private final Supplier passphraseGenerator; 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, KeyGenerator keyGenerator, PasswordService passwordService) { + this(storeFactory, passwordService, keyGenerator, () -> random(KEY_LENGTH, 0, 0, true, true, null, new SecureRandom())); } - ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService, Supplier keyGenerator) { + ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, Supplier passphraseGenerator) { this.store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build(); this.passwordService = passwordService; this.keyGenerator = keyGenerator; + this.passphraseGenerator = passphraseGenerator; } - String createNewKey(String name, String role) { + public String createNewKey(String name, String role) { String user = currentUser(); - String passphrase = keyGenerator.get(); + String passphrase = passphraseGenerator.get(); String hashedPassphrase = passwordService.encryptPassword(passphrase); Lock lock = locks.get(user).writeLock(); lock.lock(); @@ -76,7 +78,7 @@ class ApiKeyService { 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)); + final ApiKeyCollection newApiKeyCollection = apiKeyCollection.add(new ApiKeyWithPassphrase(keyGenerator.createKey(), name, role, hashedPassphrase)); store.put(user, newApiKeyCollection); } finally { lock.unlock(); @@ -84,17 +86,17 @@ class ApiKeyService { return passphrase; } - void remove(String name) { + public void remove(String id) { String user = currentUser(); Lock lock = locks.get(user).writeLock(); lock.lock(); try { - if (!containsName(user, name)) { + if (!containsId(user, id)) { return; } store.getOptional(user).ifPresent( apiKeyCollection -> { - final ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(apiKeyWithPassphrase -> name.equals(apiKeyWithPassphrase.getName())); + final ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId())); store.put(user, newApiKeyCollection); } ); @@ -103,7 +105,7 @@ class ApiKeyService { } } - Optional check(String user, String keyName, String passphrase) { + Optional check(String user, String id, String passphrase) { Lock lock = locks.get(user).readLock(); lock.lock(); try { @@ -111,7 +113,7 @@ class ApiKeyService { .get(user) .getKeys() .stream() - .filter(key -> key.getName().equals(keyName)) + .filter(key -> key.getId().equals(id)) .filter(key -> passwordService.passwordsMatch(passphrase, key.getPassphrase())) .map(ApiKeyWithPassphrase::getRole) .findAny(); @@ -120,7 +122,7 @@ class ApiKeyService { } } - Collection getKeys() { + public Collection getKeys() { return store.get(currentUser()).getKeys().stream().map(ApiKey::new).collect(toList()); } @@ -128,12 +130,21 @@ class ApiKeyService { return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString(); } + private boolean containsId(String user, String id) { + return store + .getOptional(user) + .map(ApiKeyCollection::getKeys) + .orElse(emptyList()) + .stream() + .anyMatch(key -> key.getId().equals(id)); + } + private boolean containsName(String user, String name) { return store .getOptional(user) .map(ApiKeyCollection::getKeys) .orElse(emptyList()) .stream() - .anyMatch(key -> key.getName().equals(name)); + .anyMatch(key -> key.getDisplayName().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 index 3b0d6de5fa..633a42ad4c 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java @@ -37,7 +37,8 @@ import javax.xml.bind.annotation.XmlAccessorType; @Getter @XmlAccessorType(XmlAccessType.FIELD) class ApiKeyWithPassphrase { - private String name; + private String id; + private String displayName; private String role; private String passphrase; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java index d6709e5f49..71726aa7b8 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; @@ -41,6 +41,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.ContextEntry; import sonia.scm.group.GroupCollector; +import sonia.scm.security.ApiKey; +import sonia.scm.security.ApiKeyService; import sonia.scm.user.InvalidPasswordException; import sonia.scm.user.User; import sonia.scm.user.UserManager; @@ -51,7 +53,10 @@ import javax.servlet.http.HttpServletResponse; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.util.Arrays; +import static com.google.inject.util.Providers.of; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.any; @@ -87,8 +92,13 @@ public class MeResourceTest { @Mock private UserManager userManager; + @Mock + private ApiKeyService apiKeyService; + @InjectMocks private MeDtoFactory meDtoFactory; + @InjectMocks + private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; private ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); @@ -96,6 +106,8 @@ public class MeResourceTest { private PasswordService passwordService; private User originalUser; + private MockHttpResponse response = new MockHttpResponse(); + @Before public void prepareEnvironment() { initMocks(this); @@ -106,7 +118,9 @@ public class MeResourceTest { when(groupCollector.collect("trillian")).thenReturn(ImmutableSet.of("group1", "group2")); when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod(); when(userManager.getDefaultType()).thenReturn("xml"); - MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService); + ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks); + ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper); + MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource)); when(uriInfo.getApiRestUri()).thenReturn(URI.create("/")); when(scmPathInfoStore.get()).thenReturn(uriInfo); dispatcher.addSingletonResource(meResource); @@ -118,14 +132,14 @@ public class MeResourceTest { MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2); request.accept(VndMediaType.ME); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_OK, response.getStatus()); - assertTrue(response.getContentAsString().contains("\"name\":\"trillian\"")); - assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"/v2/me/\"}")); - assertTrue(response.getContentAsString().contains("\"delete\":{\"href\":\"/v2/users/trillian\"}")); + assertThat(response.getContentAsString()).contains("\"name\":\"trillian\""); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/\"}"); + assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian\"}"); + assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/me/apiKeys\"}"); } private void applyUserToSubject(User user) { @@ -149,7 +163,6 @@ public class MeResourceTest { .put("/" + MeResource.ME_PATH_V2 + "password") .contentType(VndMediaType.PASSWORD_CHANGE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword(newPassword)).thenReturn(encryptedNewPassword); when(passwordService.encryptPassword(oldPassword)).thenReturn(encryptedOldPassword); @@ -174,7 +187,6 @@ public class MeResourceTest { .put("/" + MeResource.ME_PATH_V2 + "password") .contentType(VndMediaType.PASSWORD_CHANGE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -190,7 +202,6 @@ public class MeResourceTest { .put("/" + MeResource.ME_PATH_V2 + "password") .contentType(VndMediaType.PASSWORD_CHANGE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -206,7 +217,6 @@ public class MeResourceTest { .put("/" + MeResource.ME_PATH_V2 + "password") .contentType(VndMediaType.PASSWORD_CHANGE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); doThrow(new InvalidPasswordException(ContextEntry.ContextBuilder.entity("passwortChange", "-"))) .when(userManager).changePasswordForLoggedInUser(any(), any()); @@ -216,6 +226,33 @@ public class MeResourceTest { assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus()); } + @Test + public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.getKeys()).thenReturn(Arrays.asList(new ApiKey("1", "key 1", "READ"), new ApiKey("2", "key 2", "WRITE"))); + + MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "apiKeys"); + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"role\":\"READ\""); + assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"role\":\"WRITE\""); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/apiKeys\"}"); + } + + @Test + public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.getKeys()).thenReturn(Arrays.asList(new ApiKey("1", "key 1", "READ"), new ApiKey("2", "key 2", "WRITE"))); + + MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "apiKeys/1"); + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\""); + assertThat(response.getContentAsString()).contains("\"role\":\"READ\""); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/apiKeys/1\"}"); + } private User createDummyUser(String name) { User user = new User(); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index e7c21f5556..2bec6f3166 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -80,6 +80,8 @@ public class ResourceLinksMock { lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo)); + lenient().when(resourceLinks.apiKeyCollection()).thenReturn(new ResourceLinks.ApiKeyCollectionLinks(pathInfo)); + lenient().when(resourceLinks.apiKey()).thenReturn(new ResourceLinks.ApiKeyLinks(pathInfo)); return resourceLinks; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java index 2cec89f7ec..1609bc0065 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; 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 82371932d7..df69eee8df 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java @@ -49,12 +49,14 @@ import static org.mockito.Mockito.when; class ApiKeyServiceTest { int nextKey = 1; + int nextId = 1; PasswordService passwordService = mock(PasswordService.class); - Supplier keyGenerator = () -> Integer.toString(nextKey++); + Supplier passphraseGenerator = () -> Integer.toString(nextKey++); + KeyGenerator keyGenerator = () -> Integer.toString(nextId++); ConfigurationEntryStoreFactory storeFactory = new InMemoryConfigurationEntryStoreFactory(); ConfigurationEntryStore store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build(); - ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator); + ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator, passphraseGenerator); @BeforeEach @@ -128,7 +130,7 @@ class ApiKeyServiceTest { assertThat(service.check("dent", "1", firstKey)).contains("READ"); assertThat(service.check("dent", "2", secondKey)).contains("WRITE"); - assertThat(service.getKeys()).extracting("name").contains("1", "2"); + assertThat(service.getKeys()).extracting("id").contains("1", "2"); } @Test