diff --git a/CHANGELOG.md b/CHANGELOG.md index 090eb9b77a..99919f4167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + ### Added - Add repository import via dump file for Subversion ([#1471](https://github.com/scm-manager/scm-manager/pull/1471)) - Add support for permalinks to lines in source code view ([#1472](https://github.com/scm-manager/scm-manager/pull/1472)) +### Fixed +- Add "Api Key" page link to sub-navigation of "User" and "Me" sections ([#1464](https://github.com/scm-manager/scm-manager/pull/1464)) + ## [2.11.1] - 2020-12-07 ### Fixed - Initialization of new git repository with master set as default branch ([#1467](https://github.com/scm-manager/scm-manager/issues/1467) and [#1470](https://github.com/scm-manager/scm-manager/pull/1470)) diff --git a/docs/de/user/user/assets/user-settings-apikeys.png b/docs/de/user/user/assets/user-settings-apikeys.png new file mode 100644 index 0000000000..924c2a54fd Binary files /dev/null and b/docs/de/user/user/assets/user-settings-apikeys.png differ diff --git a/docs/de/user/user/assets/user-settings-general.png b/docs/de/user/user/assets/user-settings-general.png index 9f63014b71..e45f64d7f6 100644 Binary files a/docs/de/user/user/assets/user-settings-general.png and b/docs/de/user/user/assets/user-settings-general.png differ diff --git a/docs/de/user/user/assets/user-settings-password.png b/docs/de/user/user/assets/user-settings-password.png index a05e1a2730..ed5bc5aa0e 100644 Binary files a/docs/de/user/user/assets/user-settings-password.png and b/docs/de/user/user/assets/user-settings-password.png differ diff --git a/docs/de/user/user/assets/user-settings-publickeys.png b/docs/de/user/user/assets/user-settings-publickeys.png index b40d52e2a8..a14ff2da6f 100644 Binary files a/docs/de/user/user/assets/user-settings-publickeys.png and b/docs/de/user/user/assets/user-settings-publickeys.png differ diff --git a/docs/de/user/user/settings.md b/docs/de/user/user/settings.md index 677533bbf6..40ace64442 100644 --- a/docs/de/user/user/settings.md +++ b/docs/de/user/user/settings.md @@ -28,3 +28,8 @@ Für die einzelnen Rechte sind Tooltips verfügbar, welche Auskunft über die Au Es können öffentliche Schlüssel (Public Keys) zu Benutzern hinzugefügt werden, um die Changeset Signaturen damit zu verifizieren. ![Öffentliche Schlüssel](assets/user-settings-publickeys.png) + +### API Schlüssel +Zur Nutzung in anderen Systemen wie z. B. CI Systemen können sogenannte API Schlüssel erstellt werden. Sie können für den Zugriff auf Repositories über die REST API sowie über SCM-Clients genutzt werden. Weitere Informationen zu API Schlüsseln befinden sich im [Abschnitt Profil](../profile/#api-schlüssel). + +![API Schlüssel](assets/user-settings-apikeys.png) diff --git a/docs/en/user/user/assets/user-settings-apikeys.png b/docs/en/user/user/assets/user-settings-apikeys.png new file mode 100644 index 0000000000..381bc6790f Binary files /dev/null and b/docs/en/user/user/assets/user-settings-apikeys.png differ diff --git a/docs/en/user/user/assets/user-settings-general.png b/docs/en/user/user/assets/user-settings-general.png index 10d3688e47..aa47fa8464 100644 Binary files a/docs/en/user/user/assets/user-settings-general.png and b/docs/en/user/user/assets/user-settings-general.png differ diff --git a/docs/en/user/user/assets/user-settings-password.png b/docs/en/user/user/assets/user-settings-password.png index 7915dcfb36..acaf04876b 100644 Binary files a/docs/en/user/user/assets/user-settings-password.png and b/docs/en/user/user/assets/user-settings-password.png differ diff --git a/docs/en/user/user/assets/user-settings-publickeys.png b/docs/en/user/user/assets/user-settings-publickeys.png index 286f46d8f2..59165dcc20 100644 Binary files a/docs/en/user/user/assets/user-settings-publickeys.png and b/docs/en/user/user/assets/user-settings-publickeys.png differ diff --git a/docs/en/user/user/settings.md b/docs/en/user/user/settings.md index 9b97bad2c8..0be4ce1cea 100644 --- a/docs/en/user/user/settings.md +++ b/docs/en/user/user/settings.md @@ -28,3 +28,9 @@ There is a tooltip for each permission that provide some more details about the Add public keys to users to enable changeset signature verification. ![Public keys](assets/user-settings-publickeys.png) + +### API Keys +To access SCM-Manager from other systems like for example CI servers, API keys can be created. They can be used to call +the REST API and for the access with SCM clients. Read more about API keys in the [Profile section](../profile/#api-keys). + +![API Keys](assets/user-settings-apikeys.png) diff --git a/scm-ui/ui-webapp/src/containers/Profile.tsx b/scm-ui/ui-webapp/src/containers/Profile.tsx index 0828f71ec5..38886aa523 100644 --- a/scm-ui/ui-webapp/src/containers/Profile.tsx +++ b/scm-ui/ui-webapp/src/containers/Profile.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React from "react"; -import { Route, RouteComponentProps, withRouter } from "react-router-dom"; +import { Redirect, Route, RouteComponentProps, Switch, withRouter } from "react-router-dom"; import { getMe } from "../modules/auth"; import { compose } from "redux"; import { connect } from "react-redux"; @@ -37,16 +37,16 @@ import { SecondaryNavigationColumn, SecondaryNavigation, SubNavigation, - StateMenuContextProvider + StateMenuContextProvider, + urls } from "@scm-manager/ui-components"; import ChangeUserPassword from "./ChangeUserPassword"; import ProfileInfo from "./ProfileInfo"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; +import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys"; -import SetPublicKeyNavLink from "../users/components/navLinks/SetPublicKeysNavLink"; +import SetPublicKeysNavLink from "../users/components/navLinks/SetPublicKeysNavLink"; import SetApiKeys from "../users/components/apiKeys/SetApiKeys"; -import SetApiKeyNavLink from "../users/components/navLinks/SetApiKeysNavLink"; -import { urls } from "@scm-manager/ui-components"; +import SetApiKeysNavLink from "../users/components/navLinks/SetApiKeysNavLink"; type Props = RouteComponentProps & WithTranslation & { @@ -57,24 +57,22 @@ type Props = RouteComponentProps & }; class Profile extends React.Component { - mayChangePassword = () => { - const { me } = this.props; - return !!me?._links?.password; - }; + mayChangePassword = () => !!this.props.me?._links?.password; + canManagePublicKeys = () => !!this.props.me?._links?.publicKeys; + canManageApiKeys = () => !!this.props.me?._links?.apiKeys; - canManagePublicKeys = () => { + shouldRenderNavigation = () => { const { me } = this.props; - return !!me?._links?.publicKeys; - }; - - canManageApiKeys = () => { - const { me } = this.props; - return !!me?._links?.apiKeys; + return ( + !!me?._links?.password || + !!me?._links?.publicKeys || + !!me?._links?.apiKeys || + binder.hasExtension("profile.route") + ); }; render() { const url = urls.matchedUrl(this.props); - const { me, t } = this.props; if (!me) { @@ -101,6 +99,19 @@ class Profile extends React.Component { } /> + {this.shouldRenderNavigation() && ( + + {this.mayChangePassword() && ( + + )} + {this.canManagePublicKeys() && ( + + )} + {this.canManageApiKeys() && ( + + )} + + )} {this.mayChangePassword() && ( } /> )} @@ -120,15 +131,17 @@ class Profile extends React.Component { label={t("profile.informationNavLink")} title={t("profile.informationNavLink")} /> - {this.mayChangePassword() && ( + {this.shouldRenderNavigation() && ( - - - + {this.mayChangePassword() && ( + + )} + + )} diff --git a/scm-ui/ui-webapp/src/users/components/navLinks/index.ts b/scm-ui/ui-webapp/src/users/components/navLinks/index.ts index f732ea83ee..8c5c08345b 100644 --- a/scm-ui/ui-webapp/src/users/components/navLinks/index.ts +++ b/scm-ui/ui-webapp/src/users/components/navLinks/index.ts @@ -26,3 +26,4 @@ export { default as EditUserNavLink } from "./EditUserNavLink"; export { default as SetPasswordNavLink } from "./SetPasswordNavLink"; export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink"; export { default as SetPublicKeysNavLink } from "./SetPublicKeysNavLink"; +export { default as SetApiKeysNavLink } from "./SetApiKeysNavLink"; diff --git a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx index 8faddda5d8..2afe4d0074 100644 --- a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx +++ b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx @@ -36,18 +36,25 @@ import { SecondaryNavigationColumn, SecondaryNavigation, SubNavigation, - StateMenuContextProvider + StateMenuContextProvider, + urls } from "@scm-manager/ui-components"; import { Details } from "./../components/table"; import EditUser from "./EditUser"; import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users"; -import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks"; +import { + EditUserNavLink, + SetPasswordNavLink, + SetPermissionsNavLink, + SetPublicKeysNavLink, + SetApiKeysNavLink +} from "./../components/navLinks"; import { WithTranslation, withTranslation } from "react-i18next"; import { mustGetUsersLink } from "../../modules/indexResource"; import SetUserPassword from "../components/SetUserPassword"; import SetPermissions from "../../permissions/components/SetPermissions"; import SetPublicKeys from "../components/publicKeys/SetPublicKeys"; -import { urls } from "@scm-manager/ui-components"; +import SetApiKeys from "../components/apiKeys/SetApiKeys"; type Props = RouteComponentProps & WithTranslation & { @@ -96,10 +103,8 @@ class SingleUser extends React.Component { path={`${url}/settings/permissions`} component={() => } /> - } - /> + } /> + } /> @@ -121,6 +126,7 @@ class SingleUser extends React.Component { + 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 29c2a83177..6888b4dbbc 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 @@ -47,11 +47,11 @@ public class ApiKeyCollectionToDtoMapper { this.resourceLinks = resourceLinks; } - public HalRepresentation map(Collection keys) { - List dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList()); + public HalRepresentation map(Collection keys, String user) { + List 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)); } } 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 04dd186b2a..37123e2785 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 @@ -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(); + } } 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 index a15b351e39..75d543e970 100644 --- 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 @@ -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()); } } 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 f51be4ae11..e6de5827dc 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 @@ -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(); 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 1aad2f3d34..fba136eed4 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 @@ -64,14 +64,14 @@ public class MeResource { private final UserManager userManager; private final PasswordService passwordService; - private final Provider apiKeyResource; + private final Provider apiKeyResourceProvider; @Inject - public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider apiKeyResource) { + public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider 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(); } } 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 6aade4f134..8bc55a791a 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 @@ -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(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserApiKeyResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserApiKeyResource.java new file mode 100644 index 0000000000..257051a45c --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserApiKeyResource.java @@ -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); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserRootResource.java index d0a462fcef..263cca5747 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserRootResource.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; @@ -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; private final Provider userResource; + private final Provider userApiKeyResource; @Inject public UserRootResource(Provider userCollectionResource, - Provider userResource) { + Provider userResource, Provider 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(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java index 97aca8c1fb..a969dc74b0 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java @@ -66,6 +66,7 @@ public abstract class UserToUserDtoMapper extends BaseMapper { 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 { 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 f0673380c3..0efe61c1ab 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java @@ -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 getKeys() { - return store.getOptional(currentUser()) + public Collection 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) diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ApiKeyCollectionToDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ApiKeyCollectionToDtoMapperTest.java new file mode 100644 index 0000000000..a4a1434253 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ApiKeyCollectionToDtoMapperTest.java @@ -0,0 +1,68 @@ +/* + * 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 org.junit.Test; +import sonia.scm.security.ApiKey; + +import java.net.URI; +import java.util.Collections; + +import static java.time.Instant.now; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApiKeyCollectionToDtoMapperTest { + + public static final ApiKey API_KEY = new ApiKey("1", "key 1", "READ", now()); + + private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper = mock(ApiKeyToApiKeyDtoMapper.class); + private final ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyDtoMapper, ResourceLinksMock.createMock(URI.create("/"))); + + @Test + public void shouldMapCollection() { + ApiKeyDto expectedApiKeyDto = new ApiKeyDto(); + when(apiKeyDtoMapper.map(API_KEY, "user")).thenReturn(expectedApiKeyDto); + + HalRepresentation halRepresentation = apiKeyCollectionToDtoMapper.map(Collections.singletonList(API_KEY), "user"); + + assertThat(halRepresentation.getEmbedded().hasItem("keys")).isTrue(); + assertThat(halRepresentation.getEmbedded().getItemsBy("keys")).containsExactly(expectedApiKeyDto); + } + + @Test + public void shouldEmbedLinks() { + ApiKeyDto expectedApiKeyDto = new ApiKeyDto(); + when(apiKeyDtoMapper.map(API_KEY, "user")).thenReturn(expectedApiKeyDto); + + HalRepresentation halRepresentation = apiKeyCollectionToDtoMapper.map(Collections.singletonList(API_KEY), "user"); + + assertThat(halRepresentation.getLinks().getLinkBy("self")).isPresent(); + assertThat(halRepresentation.getLinks().getLinkBy("create")).isPresent(); + } + +} 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 e25576f2a3..59906c918f 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 @@ -79,8 +79,9 @@ public class MeResourceTest { @Rule public ShiroRule shiro = new ShiroRule(); - private RestDispatcher dispatcher = new RestDispatcher(); - + private final RestDispatcher dispatcher = new RestDispatcher(); + private final MockHttpResponse response = new MockHttpResponse(); + private final ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); @Mock @@ -105,13 +106,10 @@ public class MeResourceTest { @InjectMocks private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; - private ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); - @Mock private PasswordService passwordService; private User originalUser; - private MockHttpResponse response = new MockHttpResponse(); @Before public void prepareEnvironment() { @@ -144,7 +142,7 @@ public class MeResourceTest { 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/api_keys\"}"); + assertThat(response.getContentAsString()).contains("\"apiKeys\":{\"href\":\"/v2/users/trillian/api_keys\"}"); } private void applyUserToSubject(User user) { @@ -233,7 +231,7 @@ public class MeResourceTest { @Test public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException { - when(apiKeyService.getKeys()) + when(apiKeyService.getKeys("trillian")) .thenReturn(asList( new ApiKey("1", "key 1", "READ", now()), new ApiKey("2", "key 2", "WRITE", now()))); @@ -245,13 +243,13 @@ public class MeResourceTest { assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"permissionRole\":\"READ\""); assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"permissionRole\":\"WRITE\""); - assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys\"}"); - assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/me/api_keys\"}"); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys\"}"); + assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/users/trillian/api_keys\"}"); } @Test public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException { - when(apiKeyService.getKeys()) + when(apiKeyService.getKeys("trillian")) .thenReturn(asList( new ApiKey("1", "key 1", "READ", now()), new ApiKey("2", "key 2", "WRITE", now()))); @@ -263,13 +261,14 @@ public class MeResourceTest { assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\""); assertThat(response.getContentAsString()).contains("\"permissionRole\":\"READ\""); - assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/me/api_keys/1\"}"); - assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/me/api_keys/1\"}"); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys/1\"}"); + assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian/api_keys/1\"}"); } @Test public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException { - when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1")); + when(apiKeyService.createNewKey("trillian","guide", "READ")) + .thenReturn(new ApiKeyService.CreationResult("abc", "1")); final MockHttpRequest request = MockHttpRequest .post("/" + MeResource.ME_PATH_V2 + "api_keys/") @@ -280,12 +279,13 @@ public class MeResourceTest { assertThat(response.getStatus()).isEqualTo(201); assertThat(response.getContentAsString()).isEqualTo("abc"); - assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/me/api_keys/1")); + assertThat(response.getOutputHeaders().get("Location")).containsExactly(URI.create("/v2/users/trillian/api_keys/1")); } @Test public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException, UnsupportedEncodingException { - when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1")); + when(apiKeyService.createNewKey("trillian","guide", "READ")) + .thenReturn(new ApiKeyService.CreationResult("abc", "1")); final MockHttpRequest request = MockHttpRequest .post("/" + MeResource.ME_PATH_V2 + "api_keys/") @@ -303,9 +303,10 @@ public class MeResourceTest { dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(204); - verify(apiKeyService).remove("1"); + verify(apiKeyService).remove("trillian","1"); } + private User createDummyUser(String name) { User user = new User(); user.setName(name); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java index 9889d4df33..082f9f8123 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java @@ -21,14 +21,13 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import org.junit.Before; import org.junit.Test; import org.mockito.InjectMocks; import org.mockito.Mock; -import sonia.scm.repository.NamespaceAndName; import java.net.URI; @@ -78,6 +77,12 @@ public class ResourceLinksTest { assertEquals(BASE_URL + UserRootResource.USERS_PATH_V2, url); } + @Test + public void shouldCreateCorrectUserApiKeysUrl() { + String url = resourceLinks.user().apiKeys("ich"); + assertEquals(BASE_URL + UserRootResource.USERS_PATH_V2 + "ich/api_keys", url); + } + @Test public void shouldCreateCorrectGroupSelfUrl() { String url = resourceLinks.group().self("nobodies"); @@ -179,6 +184,7 @@ public class ResourceLinksTest { String url = resourceLinks.source().sourceWithPath("foo", "bar", "rev", "file"); assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "foo/bar/sources/rev/file", url); } + @Test public void shouldCreateCorrectPermissionCollectionUrl() { String url = resourceLinks.source().selfWithoutRevision("space", "repo"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserApiKeyResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserApiKeyResourceTest.java new file mode 100644 index 0000000000..0d82bb5ca5 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserApiKeyResourceTest.java @@ -0,0 +1,147 @@ +/* + * 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 com.google.inject.util.Providers; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import sonia.scm.security.ApiKey; +import sonia.scm.security.ApiKeyService; +import sonia.scm.web.RestDispatcher; +import sonia.scm.web.VndMediaType; + +import javax.servlet.http.HttpServletResponse; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; + +import static java.time.Instant.now; +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +public class UserApiKeyResourceTest { + + private final RestDispatcher dispatcher = new RestDispatcher(); + private final MockHttpResponse response = new MockHttpResponse(); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); + + @Mock + private ApiKeyService apiKeyService; + + @InjectMocks + private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; + + @Before + public void prepareEnvironment() { + initMocks(this); + ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks); + UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks); + UserRootResource userRootResource = new UserRootResource(null, null, Providers.of(userApiKeyResource)); + dispatcher.addSingletonResource(userRootResource); + } + + @Test + public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.getKeys("trillian")) + .thenReturn(asList( + new ApiKey("1", "key 1", "READ", now()), + new ApiKey("2", "key 2", "WRITE", now()))); + + MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys"); + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\",\"permissionRole\":\"READ\""); + assertThat(response.getContentAsString()).contains("\"displayName\":\"key 2\",\"permissionRole\":\"WRITE\""); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys\"}"); + assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/users/trillian/api_keys\"}"); + } + + @Test + public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.getKeys("trillian")) + .thenReturn(asList( + new ApiKey("1", "key 1", "READ", now()), + new ApiKey("2", "key 2", "WRITE", now()))); + + MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/1"); + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + + assertThat(response.getContentAsString()).contains("\"displayName\":\"key 1\""); + assertThat(response.getContentAsString()).contains("\"permissionRole\":\"READ\""); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/trillian/api_keys/1\"}"); + assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/users/trillian/api_keys/1\"}"); + } + + @Test + public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.createNewKey("trillian", "guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1")); + + final MockHttpRequest request = MockHttpRequest + .post("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/") + .contentType(VndMediaType.API_KEY) + .content("{\"displayName\":\"guide\",\"permissionRole\":\"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/users/trillian/api_keys/1")); + } + + @Test + public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException { + when(apiKeyService.createNewKey("trillian", "guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1")); + + final MockHttpRequest request = MockHttpRequest + .post("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/") + .contentType(VndMediaType.API_KEY) + .content("{\"displayName\":\"guide\",\"pemissionRole\":\"\"}".getBytes()); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + } + + @Test + public void shouldDeleteExistingApiKey() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.delete("/" + UserRootResource.USERS_PATH_V2 + "trillian/api_keys/1"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(apiKeyService).remove("trillian", "1"); + } +} 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 f6bdd4b4eb..486465dc79 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 @@ -28,7 +28,6 @@ import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.io.Resources; import com.google.inject.util.Providers; -import com.sun.mail.iap.Argument; import org.apache.shiro.authc.credential.PasswordService; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; @@ -42,6 +41,7 @@ import org.mockito.Mock; import sonia.scm.ContextEntry; import sonia.scm.NotFoundException; import sonia.scm.PageResult; +import sonia.scm.security.ApiKeyService; import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionDescriptor; import sonia.scm.user.ChangePasswordNotAllowedException; @@ -83,7 +83,7 @@ public class UserRootResourceTest { @Rule public ShiroRule shiro = new ShiroRule(); - private RestDispatcher dispatcher = new RestDispatcher(); + private final RestDispatcher dispatcher = new RestDispatcher(); private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); @@ -92,6 +92,8 @@ public class UserRootResourceTest { @Mock private UserManager userManager; @Mock + private ApiKeyService apiKeyService; + @Mock private PermissionAssigner permissionAssigner; @InjectMocks private UserDtoToUserMapperImpl dtoToUserMapper; @@ -99,6 +101,8 @@ public class UserRootResourceTest { private UserToUserDtoMapperImpl userToDtoMapper; @InjectMocks private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; + @InjectMocks + private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; @Captor private ArgumentCaptor userCaptor; @@ -122,8 +126,10 @@ public class UserRootResourceTest { userCollectionToDtoMapper, resourceLinks, passwordService); UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper); UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService, userPermissionResource); + ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks); + UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks); UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource), - Providers.of(userResource)); + Providers.of(userResource), Providers.of(userApiKeyResource)); dispatcher.addSingletonResource(userRootResource); } 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 adb7d5b0ba..b19b1f8999 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java @@ -89,7 +89,7 @@ class ApiKeyServiceTest { @Test void shouldCreateNewKeyAndStoreItHashed() { - service.createNewKey("1", "READ"); + service.createNewKey("dent","1", "READ"); ApiKeyCollection apiKeys = store.get("dent"); @@ -105,7 +105,7 @@ class ApiKeyServiceTest { @Test void shouldReturnRoleForKey() { - String newKey = service.createNewKey("1", "READ").getToken(); + String newKey = service.createNewKey("dent","1", "READ").getToken(); ApiKeyService.CheckResult role = service.check(newKey); @@ -114,20 +114,20 @@ class ApiKeyServiceTest { @Test void shouldHandleNewUser() { - assertThat(service.getKeys()).isEmpty(); + assertThat(service.getKeys("zaphod")).isEmpty(); } @Test void shouldNotReturnAnythingWithWrongKey() { - service.createNewKey("1", "READ"); + service.createNewKey("dent","1", "READ"); assertThrows(AuthorizationException.class, () -> service.check("dent", "1", "wrong")); } @Test void shouldAddSecondKey() { - ApiKeyService.CreationResult firstKey = service.createNewKey("1", "READ"); - ApiKeyService.CreationResult secondKey = service.createNewKey("2", "WRITE"); + ApiKeyService.CreationResult firstKey = service.createNewKey("dent","1", "READ"); + ApiKeyService.CreationResult secondKey = service.createNewKey("dent","2", "WRITE"); ApiKeyCollection apiKeys = store.get("dent"); @@ -136,16 +136,16 @@ class ApiKeyServiceTest { assertThat(service.check(firstKey.getToken())).extracting("permissionRole").isEqualTo("READ"); assertThat(service.check(secondKey.getToken())).extracting("permissionRole").isEqualTo("WRITE"); - assertThat(service.getKeys()).extracting("id") + assertThat(service.getKeys("dent")).extracting("id") .contains(firstKey.getId(), secondKey.getId()); } @Test void shouldRemoveKey() { - String firstKey = service.createNewKey("first", "READ").getToken(); - String secondKey = service.createNewKey("second", "WRITE").getToken(); + String firstKey = service.createNewKey("dent","first", "READ").getToken(); + String secondKey = service.createNewKey("dent","second", "WRITE").getToken(); - service.remove("1"); + service.remove("dent","1"); assertThrows(AuthorizationException.class, () -> service.check(firstKey)); assertThat(service.check(secondKey)).extracting("permissionRole").isEqualTo("WRITE"); @@ -153,23 +153,23 @@ class ApiKeyServiceTest { @Test void shouldFailWhenAddingSameNameTwice() { - String firstKey = service.createNewKey("1", "READ").getToken(); + String firstKey = service.createNewKey("dent","1", "READ").getToken(); - assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE")); + assertThrows(AlreadyExistsException.class, () -> service.createNewKey("dent","1", "WRITE")); assertThat(service.check(firstKey)).extracting("permissionRole").isEqualTo("READ"); } @Test void shouldIgnoreCorrectPassphraseWithWrongName() { - String firstKey = service.createNewKey("1", "READ").getToken(); + String firstKey = service.createNewKey("dent","1", "READ").getToken(); assertThrows(AuthorizationException.class, () -> service.check("dent", "other", firstKey)); } @Test void shouldDeleteTokensWhenUserIsDeleted() { - service.createNewKey("1", "READ").getToken(); + service.createNewKey("dent","1", "READ").getToken(); assertThat(store.get("dent").getKeys()).hasSize(1);