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 index 17d8094380..0ff3df4f0f 100644 --- 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 @@ -33,6 +33,7 @@ import javax.inject.Inject; import java.util.Collection; import java.util.List; +import static de.otto.edison.hal.Link.link; import static java.util.stream.Collectors.toList; public class ApiKeyCollectionToDtoMapper { @@ -48,8 +49,9 @@ public class ApiKeyCollectionToDtoMapper { 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()); + final Links.Builder links = Links.linkingTo() + .self(resourceLinks.apiKeyCollection().self()) + .single(link("create", resourceLinks.apiKeyCollection().create())); 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 index c9a40c26e1..3c6790bbb6 100644 --- 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 @@ -27,10 +27,12 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@NoArgsConstructor public class ApiKeyDto extends HalRepresentation { private String displayName; private String role; 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 index 3d8ac580f8..bcc8b3c7a3 100644 --- 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 @@ -26,6 +26,7 @@ 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.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -35,11 +36,19 @@ import sonia.scm.security.ApiKeyService; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.net.URI; + +import static javax.ws.rs.core.Response.Status.CREATED; import static sonia.scm.NotFoundException.notFound; public class ApiKeyResource { @@ -47,12 +56,14 @@ public class ApiKeyResource { private final ApiKeyService apiKeyService; private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper; private final ApiKeyToApiKeyDtoMapper apiKeyMapper; + private final ResourceLinks resourceLinks; @Inject - public ApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper) { + public ApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper, ResourceLinks links) { this.apiKeyService = apiKeyService; this.apiKeyCollectionMapper = apiKeyCollectionMapper; this.apiKeyMapper = apiKeyMapper; + this.resourceLinks = links; } @GET @@ -114,4 +125,37 @@ public class ApiKeyResource { .map(apiKeyMapper::map).findAny() .orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id))); } + + @POST + @Path("") + @Consumes(VndMediaType.API_KEY) + @Operation(summary = "Create new api key for the current user", description = "Creates a new api key for the given user with the role specified in the given key.", tags = "User") + @ApiResponse( + responseCode = "201", + description = "create success", + headers = @Header( + name = "Location", + description = "uri to the created user", + schema = @Schema(type = "string") + ), + content = @Content( + mediaType = MediaType.TEXT_PLAIN + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "409", description = "conflict, a key with the given display name already exists") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public Response create(@Valid ApiKeyDto apiKey) { + final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(apiKey.getDisplayName(), apiKey.getRole()); + return Response.status(CREATED) + .entity(newKey.getToken()) + .location(URI.create(resourceLinks.apiKey().self(newKey.getId()))) + .build(); + } } 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 945d62a73b..cfc7814731 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 @@ -218,6 +218,10 @@ class ResourceLinks { String self() { return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href(); } + + String create() { + return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters().href(); + } } public ApiKeyLinks apiKey() { 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 63994b3fd9..f3ca75ef86 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java @@ -25,6 +25,8 @@ package sonia.scm.security; import com.google.common.util.concurrent.Striped; +import lombok.AllArgsConstructor; +import lombok.Getter; import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.util.ThreadContext; @@ -70,11 +72,12 @@ public class ApiKeyService { this.passphraseGenerator = passphraseGenerator; } - public String createNewKey(String name, String role) { + public CreationResult createNewKey(String name, String role) { String user = currentUser(); String passphrase = passphraseGenerator.get(); String hashedPassphrase = passwordService.encryptPassword(passphrase); - ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(keyGenerator.createKey(), name, role, hashedPassphrase); + final String id = keyGenerator.createKey(); + ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, name, role, hashedPassphrase); Lock lock = locks.get(user).writeLock(); lock.lock(); try { @@ -87,7 +90,8 @@ public class ApiKeyService { } finally { lock.unlock(); } - return tokenHandler.createToken(user, new ApiKey(key), passphrase); + final String token = tokenHandler.createToken(user, new ApiKey(key), passphrase); + return new CreationResult(token, id); } public void remove(String id) { @@ -160,4 +164,11 @@ public class ApiKeyService { .stream() .anyMatch(key -> key.getDisplayName().equals(name)); } + + @Getter + @AllArgsConstructor + public static class CreationResult { + private final String token; + private final String id; + } } 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 71726aa7b8..63b6092d35 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 @@ -119,7 +119,7 @@ public class MeResourceTest { when(userManager.isTypeDefault(userCaptor.capture())).thenCallRealMethod(); when(userManager.getDefaultType()).thenReturn("xml"); ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks); - ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper); + ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks); MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource)); when(uriInfo.getApiRestUri()).thenReturn(URI.create("/")); when(scmPathInfoStore.get()).thenReturn(uriInfo); @@ -238,6 +238,7 @@ public class MeResourceTest { 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\"}"); + assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/me/apiKeys\"}"); } @Test @@ -254,6 +255,22 @@ public class MeResourceTest { assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/apiKeys/1\"}"); } + @Test + public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1")); + + final MockHttpRequest request = MockHttpRequest + .post("/" + MeResource.ME_PATH_V2 + "apiKeys/") + .contentType(VndMediaType.API_KEY) + .content("{\"displayName\":\"guide\",\"role\":\"READ\"}".getBytes()); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(201); + assertThat(response.getContentAsString()).isEqualTo("abc"); + assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/me/apiKeys/1")); + } + private User createDummyUser(String name) { User user = new User(); user.setName(name); 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 28a2f16659..4f5a122a8c 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java @@ -101,7 +101,7 @@ class ApiKeyServiceTest { @Test void shouldReturnRoleForKey() { - String newKey = service.createNewKey("1", "READ"); + String newKey = service.createNewKey("1", "READ").getToken(); Optional role = service.check(newKey); @@ -119,23 +119,24 @@ class ApiKeyServiceTest { @Test void shouldAddSecondKey() { - String firstKey = service.createNewKey("1", "READ"); - String secondKey = service.createNewKey("2", "WRITE"); + ApiKeyService.CreationResult firstKey = service.createNewKey("1", "READ"); + ApiKeyService.CreationResult secondKey = service.createNewKey("2", "WRITE"); ApiKeyCollection apiKeys = store.get("dent"); assertThat(apiKeys.getKeys()).hasSize(2); - assertThat(service.check(firstKey)).contains("READ"); - assertThat(service.check(secondKey)).contains("WRITE"); + assertThat(service.check(firstKey.getToken())).contains("READ"); + assertThat(service.check(secondKey.getToken())).contains("WRITE"); - assertThat(service.getKeys()).extracting("id").contains("1", "2"); + assertThat(service.getKeys()).extracting("id") + .contains(firstKey.getId(), secondKey.getId()); } @Test void shouldRemoveKey() { - String firstKey = service.createNewKey("1", "READ"); - String secondKey = service.createNewKey("2", "WRITE"); + String firstKey = service.createNewKey("1", "READ").getToken(); + String secondKey = service.createNewKey("2", "WRITE").getToken(); service.remove("1"); @@ -145,7 +146,7 @@ class ApiKeyServiceTest { @Test void shouldFailWhenAddingSameNameTwice() { - String firstKey = service.createNewKey("1", "READ"); + String firstKey = service.createNewKey("1", "READ").getToken(); assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE")); @@ -154,7 +155,7 @@ class ApiKeyServiceTest { @Test void shouldIgnoreCorrectPassphraseWithWrongName() { - String firstKey = service.createNewKey("1", "READ"); + String firstKey = service.createNewKey("1", "READ").getToken(); assertThat(service.check("dent", "other", firstKey)).isEmpty(); }