Feature/profile navigation (#1464)

- Fix bug where profile settings wasn't shown if user cannot change password
- Add missing "ApiKey" entry in the single user menu

Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Florian Scholdei
2020-12-16 09:23:05 +01:00
committed by GitHub
parent 3f018c2255
commit 88b93dc8b8
30 changed files with 598 additions and 132 deletions

View File

@@ -47,11 +47,11 @@ public class ApiKeyCollectionToDtoMapper {
this.resourceLinks = resourceLinks;
}
public HalRepresentation map(Collection<ApiKey> keys) {
List<ApiKeyDto> dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList());
public HalRepresentation map(Collection<ApiKey> keys, String user) {
List<ApiKeyDto> dtos = keys.stream().map(key -> apiKeyDtoMapper.map(key, user)).collect(toList());
final Links.Builder links = Links.linkingTo()
.self(resourceLinks.apiKeyCollection().self())
.single(link("create", resourceLinks.apiKeyCollection().create()));
.self(resourceLinks.apiKeyCollection().self(user))
.single(link("create", resourceLinks.apiKeyCollection().create(user)));
return new HalRepresentation(links.build(), Embedded.embedded("keys", dtos));
}
}

View File

@@ -32,6 +32,7 @@ import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.SecurityUtils;
import sonia.scm.ContextEntry;
import sonia.scm.security.ApiKey;
import sonia.scm.security.ApiKeyService;
@@ -48,13 +49,17 @@ 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 {
/**
* Use {@link UserApiKeyResource} instead.
* @deprecated
*/
@Deprecated
public class ApiKeyResource {
private final ApiKeyService apiKeyService;
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
@@ -90,7 +95,8 @@ public class ApiKeyResource {
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getForCurrentUser() {
return apiKeyCollectionMapper.map(apiKeyService.getKeys());
String currentUser = getCurrentUser();
return apiKeyCollectionMapper.map(apiKeyService.getKeys(currentUser), currentUser);
}
@GET
@@ -121,11 +127,13 @@ public class ApiKeyResource {
schema = @Schema(implementation = ErrorDto.class)
))
public ApiKeyDto get(@PathParam("id") String id) {
String currentUser = getCurrentUser();
return apiKeyService
.getKeys()
.getKeys(currentUser)
.stream()
.filter(key -> key.getId().equals(id))
.map(apiKeyMapper::map).findAny()
.map(key -> apiKeyMapper.map(key, currentUser)).findAny()
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, id)));
}
@@ -171,10 +179,12 @@ public class ApiKeyResource {
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@Valid ApiKeyDto apiKey) {
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(apiKey.getDisplayName(), apiKey.getPermissionRole());
String currentUser = getCurrentUser();
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(currentUser, apiKey.getDisplayName(), apiKey.getPermissionRole());
return Response.status(CREATED)
.entity(newKey.getToken())
.location(URI.create(resourceLinks.apiKey().self(newKey.getId())))
.location(URI.create(resourceLinks.apiKey().self(newKey.getId(), currentUser)))
.build();
}
@@ -185,7 +195,10 @@ public class ApiKeyResource {
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "500", description = "internal server error")
public void delete(@PathParam("id") String id) {
apiKeyService.remove(id);
apiKeyService.remove(getCurrentUser(), id);
}
private String getCurrentUser() {
return SecurityUtils.getSubject().getPrincipals().getPrimaryPrincipal().toString();
}
}

View File

@@ -39,13 +39,13 @@ public abstract class ApiKeyToApiKeyDtoMapper {
@Inject
private ResourceLinks resourceLinks;
abstract ApiKeyDto map(ApiKey key);
abstract ApiKeyDto map(ApiKey key, String user);
@ObjectFactory
ApiKeyDto createDto(ApiKey key) {
ApiKeyDto createDto(ApiKey key, String user) {
Links.Builder links = Links.linkingTo()
.self(resourceLinks.apiKey().self(key.getId()))
.single(link("delete", resourceLinks.apiKey().delete(key.getId())));
.self(resourceLinks.apiKey().self(key.getId(), user))
.single(link("delete", resourceLinks.apiKey().delete(key.getId(), user)));
return new ApiKeyDto(links.build());
}
}

View File

@@ -28,8 +28,6 @@ import com.google.common.base.Strings;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import sonia.scm.group.GroupCollector;
import sonia.scm.user.EMail;
import sonia.scm.user.User;
@@ -59,8 +57,7 @@ public class MeDtoFactory extends HalAppenderMapper {
}
public MeDto create() {
PrincipalCollection principals = getPrincipalCollection();
User user = principals.oneByType(User.class);
User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class);
MeDto dto = createDto(user);
mapUserProperties(user, dto);
@@ -79,18 +76,12 @@ public class MeDtoFactory extends HalAppenderMapper {
dto.setMail(user.getMail());
}
private PrincipalCollection getPrincipalCollection() {
Subject subject = SecurityUtils.getSubject();
return subject.getPrincipals();
}
private void setGeneratedMail(User user, MeDto dto) {
if (Strings.isNullOrEmpty(user.getMail())) {
dto.setFallbackMail(eMail.getMailOrFallback(user));
}
}
private MeDto createDto(User user) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
if (UserPermissions.delete(user).isPermitted()) {
@@ -106,7 +97,7 @@ public class MeDtoFactory extends HalAppenderMapper {
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
}
if (UserPermissions.changeApiKeys(user).isPermitted()) {
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self()));
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self(user.getName())));
}
Embedded.Builder embeddedBuilder = embeddedBuilder();

View File

@@ -64,14 +64,14 @@ public class MeResource {
private final UserManager userManager;
private final PasswordService passwordService;
private final Provider<ApiKeyResource> apiKeyResource;
private final Provider<ApiKeyResource> apiKeyResourceProvider;
@Inject
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider) {
this.meDtoFactory = meDtoFactory;
this.userManager = userManager;
this.passwordService = passwordService;
this.apiKeyResource = apiKeyResource;
this.apiKeyResourceProvider = apiKeyResourceProvider;
}
/**
@@ -142,6 +142,6 @@ public class MeResource {
@Path("api_keys")
public ApiKeyResource apiKeys() {
return apiKeyResource.get();
return apiKeyResourceProvider.get();
}
}

View File

@@ -101,10 +101,12 @@ class ResourceLinks {
static class UserLinks {
private final LinkBuilder userLinkBuilder;
private final LinkBuilder publicKeyLinkBuilder;
private final LinkBuilder apiKeyLinkBuilder;
UserLinks(ScmPathInfo pathInfo) {
userLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserResource.class);
publicKeyLinkBuilder = new LinkBuilder(pathInfo, UserPublicKeyResource.class);
apiKeyLinkBuilder = new LinkBuilder(pathInfo, UserApiKeyResource.class);
}
String self(String name) {
@@ -134,6 +136,10 @@ class ResourceLinks {
public String publicKeys(String name) {
return publicKeyLinkBuilder.method("findAll").parameters(name).href();
}
public String apiKeys(String name) {
return apiKeyLinkBuilder.method("findAll").parameters(name).href();
}
}
interface WithPermissionLinks {
@@ -220,15 +226,15 @@ class ResourceLinks {
private final LinkBuilder collectionLinkBuilder;
ApiKeyCollectionLinks(ScmPathInfo pathInfo) {
this.collectionLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
this.collectionLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserApiKeyResource.class);
}
String self() {
return collectionLinkBuilder.method("apiKeys").parameters().method("getForCurrentUser").parameters().href();
String self(String username) {
return collectionLinkBuilder.method("apiKeys").parameters().method("findAll").parameters(username).href();
}
String create() {
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters().href();
String create(String username) {
return collectionLinkBuilder.method("apiKeys").parameters().method("create").parameters(username).href();
}
}
@@ -240,15 +246,15 @@ class ResourceLinks {
private final LinkBuilder apiKeyLinkBuilder;
ApiKeyLinks(ScmPathInfo pathInfo) {
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, ApiKeyResource.class);
this.apiKeyLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserApiKeyResource.class);
}
String self(String id) {
return apiKeyLinkBuilder.method("apiKeys").parameters().method("get").parameters(id).href();
String self(String id, String name) {
return apiKeyLinkBuilder.method("apiKeys").parameters(name).method("get").parameters(id).href();
}
String delete(String id) {
return apiKeyLinkBuilder.method("apiKeys").parameters().method("delete").parameters(id).href();
String delete(String id, String name) {
return apiKeyLinkBuilder.method("apiKeys").parameters(name).method("delete").parameters(id).href();
}
}

View File

@@ -0,0 +1,192 @@
/*
* 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.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
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.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
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;
@Path("v2/users/{id}/api_keys")
public class UserApiKeyResource {
private final ApiKeyService apiKeyService;
private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper;
private final ApiKeyToApiKeyDtoMapper apiKeyMapper;
private final ResourceLinks resourceLinks;
@Inject
public UserApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper, ResourceLinks links) {
this.apiKeyService = apiKeyService;
this.apiKeyCollectionMapper = apiKeyCollectionMapper;
this.apiKeyMapper = apiKeyMapper;
this.resourceLinks = links;
}
@GET
@Path("")
@Produces(VndMediaType.API_KEY_COLLECTION)
@Operation(summary = "Get all api keys for user", description = "Returns all registered api keys for the given username.", tags = "User", operationId = "get_all_api_keys")
@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 findAll(@PathParam("id") String id) {
return apiKeyCollectionMapper.map(apiKeyService.getKeys(id), id);
}
@GET
@Path("{keyId}")
@Produces(VndMediaType.API_KEY)
@Operation(summary = "Get single api key for user", description = "Returns a single registered api key with the given id for user.", tags = "User", operationId = "get_single_api_key")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.API_KEY,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "404",
description = "not found / key for given id not 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, @PathParam("keyId") String keyId) {
return apiKeyService
.getKeys(id)
.stream()
.filter(key -> key.getId().equals(keyId))
.map(key -> apiKeyMapper.map(key, id))
.findAny()
.orElseThrow(() -> notFound(ContextEntry.ContextBuilder.entity(ApiKey.class, keyId)));
}
@POST
@Path("")
@Consumes(VndMediaType.API_KEY)
@Produces(MediaType.TEXT_PLAIN)
@Operation(
summary = "Create new api key for user",
description = "Creates a new api key for the given user with the role specified in the given key.",
tags = "User",
operationId = "create_api_key",
requestBody = @RequestBody(
content = @Content(
mediaType = VndMediaType.API_KEY,
schema = @Schema(implementation = CreateApiKeyDto.class),
examples = @ExampleObject(
name = "Create a new api key named readKey with READ permission role.",
value = "{\n \"displayName\":\"readKey\",\n \"permissionRole\":\"READ\"\n}",
summary = "Create new api key"
)
)
)
)
@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, @PathParam("id") String id) {
final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(id, apiKey.getDisplayName(), apiKey.getPermissionRole());
return Response.status(CREATED)
.entity(newKey.getToken())
.location(URI.create(resourceLinks.apiKey().self(newKey.getId(), id)))
.build();
}
@DELETE
@Path("{keyId}")
@Operation(summary = "Delete api key", description = "Deletes the api key with the given id for user.", tags = "User", operationId = "delete_api_key")
@ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "500", description = "internal server error")
public void delete( @PathParam("id") String id, @PathParam("keyId") String keyId) {
apiKeyService.remove(id, keyId);
}
}

View File

@@ -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;
@@ -32,7 +32,7 @@ import javax.inject.Provider;
import javax.ws.rs.Path;
/**
* RESTful Web Service Resource to manage users.
* RESTful Web Service Resource to manage users.
*/
@OpenAPIDefinition(tags = {
@Tag(name = "User", description = "User related endpoints")
@@ -44,12 +44,14 @@ public class UserRootResource {
private final Provider<UserCollectionResource> userCollectionResource;
private final Provider<UserResource> userResource;
private final Provider<UserApiKeyResource> userApiKeyResource;
@Inject
public UserRootResource(Provider<UserCollectionResource> userCollectionResource,
Provider<UserResource> userResource) {
Provider<UserResource> userResource, Provider<UserApiKeyResource> userApiKeyResource) {
this.userCollectionResource = userCollectionResource;
this.userResource = userResource;
this.userApiKeyResource = userApiKeyResource;
}
@Path("")
@@ -61,4 +63,9 @@ public class UserRootResource {
public UserResource getUserResource() {
return userResource.get();
}
@Path("{id}/api_keys")
public UserApiKeyResource apiKeys() {
return userApiKeyResource.get();
}
}

View File

@@ -66,6 +66,7 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> {
if (UserPermissions.modify(user).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.user().update(user.getName())));
linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName())));
linksBuilder.single(link("apiKeys", resourceLinks.user().apiKeys(user.getName())));
if (user.isExternal()) {
linksBuilder.single(link("convertToInternal", resourceLinks.user().toInternal(user.getName())));
} else {

View File

@@ -30,7 +30,6 @@ 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;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ContextEntry;
@@ -80,19 +79,18 @@ public class ApiKeyService {
this.passphraseGenerator = passphraseGenerator;
}
public CreationResult createNewKey(String name, String permissionRole) {
String user = currentUser();
UserPermissions.changeApiKeys(user).check();
public CreationResult createNewKey(String username, String keyDisplayName, String permissionRole) {
UserPermissions.changeApiKeys(username).check();
String passphrase = passphraseGenerator.get();
String hashedPassphrase = passwordService.encryptPassword(passphrase);
String id = keyGenerator.createKey();
ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, name, permissionRole, hashedPassphrase, now());
doSynchronized(user, true, () -> {
persistKey(name, user, key);
ApiKeyWithPassphrase key = new ApiKeyWithPassphrase(id, keyDisplayName, permissionRole, hashedPassphrase, now());
doSynchronized(username, true, () -> {
persistKey(keyDisplayName, username, key);
return null;
});
String token = tokenHandler.createToken(user, new ApiKey(key), passphrase);
LOG.info("created new api key for user {} with role {}", user, permissionRole);
String token = tokenHandler.createToken(username, new ApiKey(key), passphrase);
LOG.info("created new api key for user {} with role {}", username, permissionRole);
return new CreationResult(token, id);
}
@@ -105,18 +103,17 @@ public class ApiKeyService {
store.put(user, newApiKeyCollection);
}
public void remove(String id) {
String user = currentUser();
UserPermissions.changeApiKeys(user).check();
doSynchronized(user, true, () -> {
if (!containsId(user, id)) {
public void remove(String username, String id) {
UserPermissions.changeApiKeys(username).check();
doSynchronized(username, true, () -> {
if (!containsId(username, id)) {
return null;
}
store.getOptional(user).ifPresent(
store.getOptional(username).ifPresent(
apiKeyCollection -> {
ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId()));
store.put(user, newApiKeyCollection);
LOG.info("removed api key for user {}", user);
store.put(username, newApiKeyCollection);
LOG.info("removed api key for user {}", username);
}
);
return null;
@@ -154,8 +151,8 @@ public class ApiKeyService {
return result;
}
public Collection<ApiKey> getKeys() {
return store.getOptional(currentUser())
public Collection<ApiKey> getKeys(String user) {
return store.getOptional(user)
.map(ApiKeyCollection::getKeys)
.map(Collection::stream)
.orElse(Stream.empty())
@@ -163,10 +160,6 @@ public class ApiKeyService {
.collect(toList());
}
private String currentUser() {
return ThreadContext.getSubject().getPrincipals().getPrimaryPrincipal().toString();
}
private boolean containsId(String user, String id) {
return store
.getOptional(user)