mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-08 00:22:11 +01:00
Add rest resource for api keys
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApiKey> keys) {
|
||||
List<ApiKeyDto> 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> apiKeyResource;
|
||||
|
||||
@Inject
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) {
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
*
|
||||
* <strong>Note:</strong> 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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApiKeyCollection> store;
|
||||
private final PasswordService passwordService;
|
||||
private final Supplier<String> keyGenerator;
|
||||
private final KeyGenerator keyGenerator;
|
||||
private final Supplier<String> passphraseGenerator;
|
||||
|
||||
private final Striped<ReadWriteLock> 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<String> keyGenerator) {
|
||||
ApiKeyService(ConfigurationEntryStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, Supplier<String> 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<String> check(String user, String keyName, String passphrase) {
|
||||
Optional<String> 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<ApiKey> getKeys() {
|
||||
public Collection<ApiKey> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<User> 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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,12 +49,14 @@ import static org.mockito.Mockito.when;
|
||||
class ApiKeyServiceTest {
|
||||
|
||||
int nextKey = 1;
|
||||
int nextId = 1;
|
||||
|
||||
PasswordService passwordService = mock(PasswordService.class);
|
||||
Supplier<String> keyGenerator = () -> Integer.toString(nextKey++);
|
||||
Supplier<String> passphraseGenerator = () -> Integer.toString(nextKey++);
|
||||
KeyGenerator keyGenerator = () -> Integer.toString(nextId++);
|
||||
ConfigurationEntryStoreFactory storeFactory = new InMemoryConfigurationEntryStoreFactory();
|
||||
ConfigurationEntryStore<ApiKeyCollection> 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
|
||||
|
||||
Reference in New Issue
Block a user