diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e5fb3fb0..a2baff6e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Align actionbar item horizontal and enforce correct margin between them ([#1358](https://github.com/scm-manager/scm-manager/pull/1358)) ## [2.6.1] - 2020-09-30 +### Added +- Users can create API keys with limited permissions ([#1359](https://github.com/scm-manager/scm-manager/pull/1359)) + ### Fixed - Not found error when using browse command in empty hg repository ([#1355](https://github.com/scm-manager/scm-manager/pull/1355)) diff --git a/docs/de/navigation.yml b/docs/de/navigation.yml index bf2e185da2..0e25b8ec53 100644 --- a/docs/de/navigation.yml +++ b/docs/de/navigation.yml @@ -4,3 +4,4 @@ - /user/user/ - /user/group/ - /user/admin/ + - /user/profile/ diff --git a/docs/de/user/profile/assets/api-key-created.png b/docs/de/user/profile/assets/api-key-created.png new file mode 100644 index 0000000000..1bc4b0cc17 Binary files /dev/null and b/docs/de/user/profile/assets/api-key-created.png differ diff --git a/docs/de/user/profile/assets/api-key-overview.png b/docs/de/user/profile/assets/api-key-overview.png new file mode 100644 index 0000000000..d150b8fb1b Binary files /dev/null and b/docs/de/user/profile/assets/api-key-overview.png differ diff --git a/docs/de/user/profile/index.md b/docs/de/user/profile/index.md new file mode 100644 index 0000000000..6e399bfa13 --- /dev/null +++ b/docs/de/user/profile/index.md @@ -0,0 +1,50 @@ +--- +title: Profil +partiallyActive: true +--- + +Über den Link zum Profil im Footer können Einstellungen zum eigenen Konto vorgenommen werden. + +## Passwort ändern + +Hier kann das Passwort für das Konto geändert werden, wenn es sich um ein lokales Konto handelt (wenn die Anmeldung +also nicht über ein Fremdsystem erfolgt). Um die Änderung zu autorisieren, muss zunächst das aktuelle Passwort +eingegeben werden. Danach muss das neue Passwort zweimal eingegeben werden. + +## Öffentliche Schlüssel + +Zum Prüfen von Signaturen für z. B. Commits können hier die entsprechenden öffentlichen Schlüssel hinterlegt werden. +Zudem können hier die vom SCM-Manager erstellten Signaturschlüssel heruntergeladen werden. + +## 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. Dazu wird ein Anzeigename sowie +eine [Rolle](../admin/roles/) ausgewählt. Der Anzeigename ist ausschließlich zur Verwaltung gedacht und hat keine +weitere technische Bewandnis. Mithilfe der Rolle können die Berechtigungen eingeschränkt werden, die bei einer Anmeldung +zur Verfügung stehen. + +Hat z. B. ein Konto schreibende Rechte für ein Repository und wird ein API-Schlüssel mit der Rolle "READ" erzeugt, so +kann über diesen Schlüssel nur lesend auf das Repository zugegriffen werden. Eine Ausweitung der Rechte hingegen ist +selbstverständlich nicht möglich. Es kann also mithilfe eines API-Schlüssels mit der Rolle "WRITE" nicht schreibend auf +ein Repository zugegriffen werden, für das bei dem Konto nur ein lesender Zugriff gestattet ist. + +![API Key Overview](assets/api-key-overview.png) + +Nach der Erstellung eines Schlüssels, wird dieser **einmalig** angezeigt. Nachdem dieses Fenster +geschlossen wurde, kann der Schlüssel nicht mehr abgerufen und nicht wiederhergestellt werden. + +![API Key Created](assets/api-key-created.png) + +### Beispiel REST API + +Um einen Schlüssel mit der REST API zu nutzen, muss der Schlüssel als Cookie mit dem Namen „X-Bearer-Token“ +übergeben werden. Für die Nutzung mit curl sieht ein Aufruf z. B. wie folgt aus: + +``` +curl -v localhost:8081/scm/api/v2/repositories/ -H "Cookie: X-Bearer-Token=eyJhcGlLZXlJZCI...RTRHeCJ9" +``` + +### Zugriff mit SCM-Client + +Für einen Zugriff mit einem SCM-Client (z. B. `git`, `hg` oder `svn`) muss der Schlüssel als Passwort übergeben werden. diff --git a/docs/en/navigation.yml b/docs/en/navigation.yml index 3d5b6518a8..89a8367a9f 100644 --- a/docs/en/navigation.yml +++ b/docs/en/navigation.yml @@ -12,6 +12,7 @@ - /user/user/ - /user/group/ - /user/admin/ + - /user/profile/ - section: Administration entries: diff --git a/docs/en/user/profile/assets/api-key-created.png b/docs/en/user/profile/assets/api-key-created.png new file mode 100644 index 0000000000..1bc4b0cc17 Binary files /dev/null and b/docs/en/user/profile/assets/api-key-created.png differ diff --git a/docs/en/user/profile/assets/api-key-overview.png b/docs/en/user/profile/assets/api-key-overview.png new file mode 100644 index 0000000000..d150b8fb1b Binary files /dev/null and b/docs/en/user/profile/assets/api-key-overview.png differ diff --git a/docs/en/user/profile/index.md b/docs/en/user/profile/index.md new file mode 100644 index 0000000000..50746c2f56 --- /dev/null +++ b/docs/en/user/profile/index.md @@ -0,0 +1,49 @@ +--- +title: Profile +partiallyActive: true +--- + +Settings for the active user account can be managed with the link "Profile" in the footer. + +## Change password + +Here the password for the current account can be changed when it is a local account (when the login is not managed by an +external system). To authorize the change, the current password has to be put first. Then the new password has to be +entered twice. + +## Öffentliche Schlüssel + +To check signatures for example for commits, public keys can be stored here. Additionally the keys created by +SCM-Manager can be accessed here, too. + +## 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. To create a key you have to specify a display name and a +[role](../admin/roles/). The display name is solely to keep track of your keys. The role limits the permissions granted +when the SCM-Manager is accessed with such a key. + +If, for exapmple, an account has write access for a repository and an API key with the role "READ" is created for this +account, this repository can only be accessed read only using this key. Of course it is not possible to extend +permissions. So you cannot create an API key with the role "WRITE" to get write access to a repository, where the +original account has only read access for. + +![API Key Overview](assets/api-key-overview.png) + +After the creation of a key, it will be displayed **once only**. After the window has been closed, the key cannot be +retrieved or reconstructed again. + +![API Key Created](assets/api-key-created.png) + +### Example for the REST API + +To use an API key for the REST API, the key has to sent as a cookie with the name “X-Bearer-Token”. Using curl, this +can be done like this: + +``` +curl -v localhost:8081/scm/api/v2/repositories/ -H "Cookie: X-Bearer-Token=eyJhcGlLZXlJZCI...RTRHeCJ9" +``` + +### Access with an SCM-Client + +For access with an SCM client like `git`, `hg`, or `svn` the key simply has to be passed as a password. diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java index 1b0a3b2388..3379041100 100644 --- a/scm-core/src/main/java/sonia/scm/user/User.java +++ b/scm-core/src/main/java/sonia/scm/user/User.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.user; //~--- non-JDK imports -------------------------------------------------------- @@ -50,7 +50,7 @@ import java.security.Principal; @StaticPermissions( value = "user", globalPermissions = {"create", "list", "autocomplete"}, - permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys"}, + permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys", "changeApiKeys"}, custom = true, customGlobal = true ) @XmlRootElement(name = "users") diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 6112d233f4..b3dbcb5b6e 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -85,6 +85,9 @@ public class VndMediaType { public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX; public static final String REPOSITORY_ROLE_COLLECTION = PREFIX + "repositoryRoleCollection" + SUFFIX; + public static final String API_KEY = PREFIX + "apiKey" + SUFFIX; + public static final String API_KEY_COLLECTION = PREFIX + "apiKeyCollection" + SUFFIX; + private VndMediaType() { } diff --git a/scm-it/src/test/java/sonia/scm/it/ApiKeyITCase.java b/scm-it/src/test/java/sonia/scm/it/ApiKeyITCase.java new file mode 100644 index 0000000000..b9f75a9dec --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/ApiKeyITCase.java @@ -0,0 +1,115 @@ +/* + * 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.it; + +import io.restassured.RestAssured; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import sonia.scm.it.utils.RepositoryUtil; +import sonia.scm.it.utils.RestUtil; +import sonia.scm.it.utils.TestData; +import sonia.scm.repository.client.api.RepositoryClient; +import sonia.scm.repository.client.api.RepositoryClientException; +import sonia.scm.web.VndMediaType; + +import javax.ws.rs.core.MediaType; +import java.io.IOException; +import java.util.Objects; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static sonia.scm.it.utils.RepositoryUtil.addAndCommitRandomFile; +import static sonia.scm.it.utils.RestUtil.given; +import static sonia.scm.it.utils.TestData.WRITE; + +public class ApiKeyITCase { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Before + public void prepareEnvironment() { + TestData.createDefault(); + TestData.createNotAdminUser("user", "user"); + TestData.createUserPermission("user", WRITE, "git"); + } + + @After + public void cleanup() { + TestData.cleanup(); + } + + @Test + public void shouldCloneWithRestrictedApiKey() throws IOException { + String passphrase = registerApiKey(); + + RepositoryClient client = RepositoryUtil.createRepositoryClient("git", temporaryFolder.newFolder(), "user", passphrase); + + assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length); + } + + @Test + public void shouldFailToCommit() throws IOException { + String passphrase = registerApiKey(); + + RepositoryClient client = RepositoryUtil.createRepositoryClient("git", temporaryFolder.newFolder(), "user", passphrase); + + assertThrows(RepositoryClientException.class, () -> addAndCommitRandomFile(client, "user")); + } + + + public String registerApiKey() { + String apiKeysUrl = given(VndMediaType.ME, "user", "user") + .when() + .get(RestUtil.createResourceUrl("me/")) + .then() + .statusCode(200) + .extract() + .body().jsonPath().getString("_links.apiKeys.href"); + String createUrl = given(VndMediaType.API_KEY_COLLECTION) + .when() + .get(apiKeysUrl) + .then() + .statusCode(200) + .extract() + .body().jsonPath().getString("_links.create.href"); + String passphrase = new String(RestAssured.given() + .contentType(VndMediaType.API_KEY) + .accept(MediaType.TEXT_PLAIN) + .auth().preemptive().basic("user", "user") + .when() + .body("{\"displayName\":\"integration test\",\"permissionRole\":\"READ\"}") + .post(createUrl) + .then() + .statusCode(201) + .extract() + .body() + .asByteArray()); + return passphrase; + } +} diff --git a/scm-ui/ui-components/src/layout/Footer.tsx b/scm-ui/ui-components/src/layout/Footer.tsx index d217768cc5..12e71e373d 100644 --- a/scm-ui/ui-components/src/layout/Footer.tsx +++ b/scm-ui/ui-components/src/layout/Footer.tsx @@ -100,6 +100,7 @@ const Footer: FC = ({ me, version, links }) => { {me?._links?.password && } {me?._links?.publicKeys && } + {me?._links?.apiKeys && } }> diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index f11e606970..3fbbe0e3b2 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -71,6 +71,7 @@ "informationNavLink": "Information", "changePasswordNavLink": "Passwort ändern", "publicKeysNavLink": "Öffentliche Schlüssel", + "apiKeysNavLink": "API Schlüssel", "settingsNavLink": "Einstellungen", "username": "Benutzername", "displayName": "Anzeigename", diff --git a/scm-ui/ui-webapp/public/locales/de/users.json b/scm-ui/ui-webapp/public/locales/de/users.json index 825876ec47..0daa4b5c9a 100644 --- a/scm-ui/ui-webapp/public/locales/de/users.json +++ b/scm-ui/ui-webapp/public/locales/de/users.json @@ -38,7 +38,8 @@ "generalNavLink": "Generell", "setPasswordNavLink": "Passwort", "setPermissionsNavLink": "Berechtigungen", - "setPublicKeyNavLink": "Öffentliche Schlüssel" + "setPublicKeyNavLink": "Öffentliche Schlüssel", + "setApiKeyNavLink": "API Schlüssel" } }, "createUser": { @@ -70,5 +71,27 @@ "addKey": "Schlüssel hinzufügen", "delete": "Löschen", "download": "Herunterladen" + }, + "apiKey": { + "noStoredKeys": "Es wurden keine Schlüssel gefunden.", + "displayName": "Anzeigename", + "permissionRole": { + "label": "Berechtigte Rolle", + "help": "Mit der Rolle können Sie die Berechtigung für diesen Schlüssel einschränken" + }, + "created": "Eingetragen an", + "addKey": "Schlüssel hinzufügen", + "delete": "Löschen", + "download": "Herunterladen", + "text1": "Erstelle und verwalte Personal Access Token um auf die REST API zuzugreifen oder diese als Passwort für SCM-Clients zu nutzen. Die Rechte der Token sind auf Repositories und die gewählte Rolle beschränkt.", + "manageRoles": "Sie können die Rollenberechtigungen in der Administration unter „Berechtigungsrollen“ einsehen und neue Rollen anlegen.", + "text2": "Um den Token in REST-Abfragen zu nutzen, übergeben Sie diesen als Cookie mit dem Namen „X-Bearer-Token“. Sie können den Token auch anstelle Ihres Passworts nutzen, um sich mit SCM-Clients anzumelden.", + "modal": { + "title": "Schlüssel erzeugt", + "text1": "Ihr neuer API-Schlüssel ist bereit. Sie können diesen als Token für Zugriffe auf die REST-Schnittstelle nutzen oder anstelle Ihres Passworts zum Login mit SCM-Clients nutzen.", + "text2": "Sichern Sie Ihren API-Schlüssel jetzt! Er wird hier einmalig angezeigt und kann später nicht mehr wiederbeschafft werden.", + "clipboard": "In die Zwischenablage kopieren", + "close": "Schließen" + } } } diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index da4a68f7b2..ac98e39183 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -70,9 +70,10 @@ "profile": { "navigationLabel": "Profile", "informationNavLink": "Information", - "changePasswordNavLink": "Change password", + "changePasswordNavLink": "Change Password", "settingsNavLink": "Settings", "publicKeysNavLink": "Public Keys", + "apiKeysNavLink": "API Keys", "username": "Username", "displayName": "Display Name", "mail": "E-Mail", diff --git a/scm-ui/ui-webapp/public/locales/en/users.json b/scm-ui/ui-webapp/public/locales/en/users.json index d50618bcf0..8effc6c589 100644 --- a/scm-ui/ui-webapp/public/locales/en/users.json +++ b/scm-ui/ui-webapp/public/locales/en/users.json @@ -38,7 +38,8 @@ "generalNavLink": "General", "setPasswordNavLink": "Password", "setPermissionsNavLink": "Permissions", - "setPublicKeyNavLink": "Public Keys" + "setPublicKeyNavLink": "Public Keys", + "setApiKeyNavLink": "API Keys" } }, "createUser": { @@ -70,5 +71,27 @@ "addKey": "Add key", "delete": "Delete", "download": "Download" + }, + "apiKey": { + "noStoredKeys": "No keys found.", + "displayName": "Display Name", + "permissionRole": { + "label": "Permitted Role", + "help": "The api key will be restricted to permissions of this role" + }, + "created": "Created on", + "addKey": "Add key", + "delete": "Delete", + "download": "Download", + "text1": "Create and manage personal access tokens to access the REST API or use as a password for SCM clients. The privileges of these tokens are limited to repositories and the selected role.", + "manageRoles": "You may view and create roles in the administration view “Permission Roles”.", + "text2": "To use the token in a REST request, pass it as a cookie named “X-Bearer-Token”. You may use the token as your password for SCM clients, too.", + "modal": { + "title": "Key created", + "text1": "Your new API key is ready. You can use it as a bearer token for REST calls or as a password for SCM clients.", + "text2": "Store your API key in a safe place now! It is only displayed now and cannot be recovered later.", + "clipboard": "Copy to clipboard", + "close": "Close" + } } } diff --git a/scm-ui/ui-webapp/src/containers/Profile.tsx b/scm-ui/ui-webapp/src/containers/Profile.tsx index d1d8a065f3..0828f71ec5 100644 --- a/scm-ui/ui-webapp/src/containers/Profile.tsx +++ b/scm-ui/ui-webapp/src/containers/Profile.tsx @@ -44,6 +44,8 @@ import ProfileInfo from "./ProfileInfo"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; import SetPublicKeys from "../users/components/publicKeys/SetPublicKeys"; import SetPublicKeyNavLink 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"; type Props = RouteComponentProps & @@ -65,6 +67,11 @@ class Profile extends React.Component { return !!me?._links?.publicKeys; }; + canManageApiKeys = () => { + const { me } = this.props; + return !!me?._links?.apiKeys; + }; + render() { const url = urls.matchedUrl(this.props); @@ -100,6 +107,9 @@ class Profile extends React.Component { {this.canManagePublicKeys() && ( } /> )} + {this.canManageApiKeys() && ( + } /> + )} @@ -118,6 +128,7 @@ class Profile extends React.Component { > + )} diff --git a/scm-ui/ui-webapp/src/repos/permissions/components/RoleSelector.tsx b/scm-ui/ui-webapp/src/repos/permissions/components/RoleSelector.tsx index 536c824914..941ddc88ee 100644 --- a/scm-ui/ui-webapp/src/repos/permissions/components/RoleSelector.tsx +++ b/scm-ui/ui-webapp/src/repos/permissions/components/RoleSelector.tsx @@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next"; import { Select } from "@scm-manager/ui-components"; type Props = WithTranslation & { - availableRoles: string[]; + availableRoles?: string[]; handleRoleChange: (p: string) => void; role: string; label?: string; diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/AddApiKey.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/AddApiKey.tsx new file mode 100644 index 0000000000..216f33d425 --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/AddApiKey.tsx @@ -0,0 +1,145 @@ +/* + * 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. + */ + +import React, { FC, useEffect, useState } from "react"; +import { apiClient, ErrorNotification, InputField, Level, Loading, SubmitButton } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import { CONTENT_TYPE_API_KEY } from "./SetApiKeys"; +import { connect } from "react-redux"; +import { + fetchAvailablePermissionsIfNeeded, + getAvailableRepositoryRoles +} from "../../../repos/permissions/modules/permissions"; +import { RepositoryRole } from "@scm-manager/ui-types"; +import { getRepositoryRolesLink, getRepositoryVerbsLink } from "../../../modules/indexResource"; +import RoleSelector from "../../../repos/permissions/components/RoleSelector"; +import ApiKeyCreatedModal from "./ApiKeyCreatedModal"; + +type Props = { + createLink: string; + refresh: () => void; + repositoryRolesLink: string; + repositoryVerbsLink: string; + fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => void; + availableRepositoryRoles?: RepositoryRole[]; +}; + +const AddApiKey: FC = ({ + createLink, + refresh, + fetchAvailablePermissionsIfNeeded, + repositoryRolesLink, + repositoryVerbsLink, + availableRepositoryRoles +}) => { + const [t] = useTranslation("users"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [displayName, setDisplayName] = useState(""); + const [permissionRole, setPermissionRole] = useState(""); + const [addedKey, setAddedKey] = useState(""); + + useEffect(() => { + if (!availableRepositoryRoles) { + fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink); + } + }, [repositoryRolesLink, repositoryVerbsLink]); + + const isValid = () => { + return !!displayName && !!permissionRole; + }; + + const resetForm = () => { + setDisplayName(""); + setPermissionRole(""); + }; + + const addKey = () => { + setLoading(true); + apiClient + .post(createLink, { displayName: displayName, permissionRole: permissionRole }, CONTENT_TYPE_API_KEY) + .then(response => response.text()) + .then(setAddedKey) + .then(() => setLoading(false)) + .catch(setError); + }; + + if (error) { + return ; + } + + if (loading) { + return ; + } + + const availableRoleNames = availableRepositoryRoles ? availableRepositoryRoles.map(r => r.name) : []; + + const closeModal = () => { + resetForm(); + refresh(); + setAddedKey(""); + }; + + const newKeyModal = addedKey && ; + + return ( + <> + {newKeyModal} + + + } + /> + + ); +}; + +const mapStateToProps = (state: any, ownProps: Props) => { + const availableRepositoryRoles = getAvailableRepositoryRoles(state); + const repositoryRolesLink = getRepositoryRolesLink(state); + const repositoryVerbsLink = getRepositoryVerbsLink(state); + + return { + availableRepositoryRoles, + repositoryRolesLink, + repositoryVerbsLink + }; +}; + +const mapDispatchToProps = (dispatch: any) => { + return { + fetchAvailablePermissionsIfNeeded: (repositoryRolesLink: string, repositoryVerbsLink: string) => { + dispatch(fetchAvailablePermissionsIfNeeded(repositoryRolesLink, repositoryVerbsLink)); + } + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(AddApiKey); diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyCreatedModal.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyCreatedModal.tsx new file mode 100644 index 0000000000..2dfdcc0c01 --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyCreatedModal.tsx @@ -0,0 +1,86 @@ +/* + * 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. + */ + +import React, { FC, useRef, useState } from "react"; +import { Button, Icon, Modal } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; + +type Props = { + addedKey: string; + close: () => void; +}; + +const KeyArea = styled.textarea` + white-space: nowrap; + overflow: auto; + font-family: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro", monospace; + height: 3rem; +`; + +const NoLeftMargin = styled.div` + margin-left: -1rem; +`; + +const ApiKeyCreatedModal: FC = ({ addedKey, close }) => { + const [t] = useTranslation("users"); + const [copied, setCopied] = useState(false); + const keyRef = useRef(null); + + const copy = () => { + keyRef.current.select(); + document.execCommand("copy"); + setCopied(true); + }; + + const newPassphraseModalContent = ( +
+

{t("apiKey.modal.text1")}

+

+ {t("apiKey.modal.text2")} +

+
+
+
+ +
+ + + +
+
+ ); + + return ( + } + active={true} + /> + ); +}; + +export default ApiKeyCreatedModal; diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyEntry.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyEntry.tsx new file mode 100644 index 0000000000..ff349d11bf --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyEntry.tsx @@ -0,0 +1,63 @@ +/* + * 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. + */ + +import React, { FC } from "react"; +import { DateFromNow, Icon } from "@scm-manager/ui-components"; +import { ApiKey } from "./SetApiKeys"; +import { Link } from "@scm-manager/ui-types"; +import { useTranslation } from "react-i18next"; + +type Props = { + apiKey: ApiKey; + onDelete: (link: string) => void; +}; + +export const ApiKeyEntry: FC = ({ apiKey, onDelete }) => { + const [t] = useTranslation("users"); + let deleteButton; + if (apiKey?._links?.delete) { + deleteButton = ( + onDelete((apiKey._links.delete as Link).href)}> + + + + + ); + } + + return ( + <> + + {apiKey.displayName} + {apiKey.permissionRole} + + + + {deleteButton} + + + ); +}; + +export default ApiKeyEntry; diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyTable.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyTable.tsx new file mode 100644 index 0000000000..da98781efd --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/ApiKeyTable.tsx @@ -0,0 +1,62 @@ +/* + * 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. + */ + +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { ApiKey, ApiKeysCollection } from "./SetApiKeys"; +import ApiKeyEntry from "./ApiKeyEntry"; +import { Notification } from "@scm-manager/ui-components"; + +type Props = { + apiKeys?: ApiKeysCollection; + onDelete: (link: string) => void; +}; + +const ApiKeyTable: FC = ({ apiKeys, onDelete }) => { + const [t] = useTranslation("users"); + + if (apiKeys?._embedded?.keys?.length === 0) { + return {t("apiKey.noStoredKeys")}; + } + + return ( + + + + + + + + + + {apiKeys?._embedded?.keys?.map((apiKey: ApiKey, index: number) => { + return ; + })} + +
{t("apiKey.displayName")}{t("apiKey.permissionRole.label")}{t("apiKey.created")} +
+ ); +}; + +export default ApiKeyTable; diff --git a/scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx b/scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx new file mode 100644 index 0000000000..8d80add477 --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/apiKeys/SetApiKeys.tsx @@ -0,0 +1,110 @@ +/* + * 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. + */ + +import { Collection, Links, User, Me } from "@scm-manager/ui-types"; +import React, { FC, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components"; +import ApiKeyTable from "./ApiKeyTable"; +import AddApiKey from "./AddApiKey"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; + +export type ApiKeysCollection = Collection & { + _embedded: { + keys: ApiKey[]; + }; +}; + +export type ApiKey = { + id: string; + displayName: string; + permissionRole: string; + created: string; + _links: Links; +}; + +export const CONTENT_TYPE_API_KEY = "application/vnd.scmm-apiKey+json;v=2"; + +type Props = { + user: User | Me; +}; + +const Subtitle = styled.div` + margin-bottom: 1rem; +`; + +const SetApiKeys: FC = ({ user }) => { + const [t] = useTranslation("users"); + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + const [apiKeys, setApiKeys] = useState(undefined); + + useEffect(() => { + fetchApiKeys(); + }, [user]); + + const fetchApiKeys = () => { + setLoading(true); + apiClient + .get((user._links.apiKeys as Link).href) + .then(r => r.json()) + .then(setApiKeys) + .then(() => setLoading(false)) + .catch(setError); + }; + + const onDelete = (link: string) => { + apiClient + .delete(link) + .then(fetchApiKeys) + .catch(setError); + }; + + const createLink = (apiKeys?._links?.create as Link)?.href; + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return ( + <> +
+

{t("apiKey.text1")} {t("apiKey.manageRoles")}

+

{t("apiKey.text2")}

+
+
+ +
+

Create new key

+ {createLink && } + + ); +}; + +export default SetApiKeys; diff --git a/scm-ui/ui-webapp/src/users/components/navLinks/SetApiKeysNavLink.tsx b/scm-ui/ui-webapp/src/users/components/navLinks/SetApiKeysNavLink.tsx new file mode 100644 index 0000000000..189d9f287a --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/navLinks/SetApiKeysNavLink.tsx @@ -0,0 +1,43 @@ +/* + * 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. + */ +import React, { FC } from "react"; +import { Link, User, Me } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; + +type Props = { + user: User | Me; + apiKeyUrl: string; +}; + +const SetApiKeyNavLink: FC = ({ user, apiKeyUrl }) => { + const [t] = useTranslation("users"); + + if ((user?._links?.apiKeys as Link)?.href) { + return ; + } + return null; +}; + +export default SetApiKeyNavLink; diff --git a/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java index 0fa8113c6b..9e10b89669 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/NotSupportedExceptionMapper.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; import org.slf4j.Logger; @@ -42,7 +42,7 @@ public class NotSupportedExceptionMapper implements ExceptionMapper keys) { + List dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList()); + final Links.Builder links = Links.linkingTo() + .self(resourceLinks.apiKeyCollection().self()) + .single(link("create", resourceLinks.apiKeyCollection().create())); + return new HalRepresentation(links.build(), Embedded.embedded("keys", dtos)); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyDto.java new file mode 100644 index 0000000000..b07e329a8a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyDto.java @@ -0,0 +1,49 @@ +/* + * 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.NoArgsConstructor; +import lombok.Setter; + +import javax.validation.constraints.NotEmpty; +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +public class ApiKeyDto extends HalRepresentation { + @NotEmpty + private String displayName; + @NotEmpty + private String permissionRole; + private Instant created; + + public ApiKeyDto(Links links) { + super(links); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyResource.java new file mode 100644 index 0000000000..e2faac3e37 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyResource.java @@ -0,0 +1,173 @@ +/* + * 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.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.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; + +public class ApiKeyResource { + + private final ApiKeyService apiKeyService; + private final ApiKeyCollectionToDtoMapper apiKeyCollectionMapper; + private final ApiKeyToApiKeyDtoMapper apiKeyMapper; + private final ResourceLinks resourceLinks; + + @Inject + public ApiKeyResource(ApiKeyService apiKeyService, ApiKeyCollectionToDtoMapper apiKeyCollectionMapper, ApiKeyToApiKeyDtoMapper apiKeyMapper, ResourceLinks links) { + this.apiKeyService = apiKeyService; + this.apiKeyCollectionMapper = apiKeyCollectionMapper; + this.apiKeyMapper = apiKeyMapper; + this.resourceLinks = links; + } + + @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) + @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, + 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))); + } + + @POST + @Path("") + @Consumes(VndMediaType.API_KEY) + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Create new api key for the current user", description = "Creates a new api key for the given user with the role specified in the given key.", tags = "User") + @ApiResponse( + responseCode = "201", + description = "create success", + headers = @Header( + name = "Location", + description = "uri to the created user", + schema = @Schema(type = "string") + ), + content = @Content( + mediaType = MediaType.TEXT_PLAIN + ) + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "409", description = "conflict, a key with the given display name already exists") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public Response create(@Valid ApiKeyDto apiKey) { + final ApiKeyService.CreationResult newKey = apiKeyService.createNewKey(apiKey.getDisplayName(), apiKey.getPermissionRole()); + return Response.status(CREATED) + .entity(newKey.getToken()) + .location(URI.create(resourceLinks.apiKey().self(newKey.getId()))) + .build(); + } + + @DELETE + @Path("{id}") + @Operation(summary = "Delete api key", description = "Deletes the api key with the given id for the current user.", tags = "User") + @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) { + apiKeyService.remove(id); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyToApiKeyDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyToApiKeyDtoMapper.java new file mode 100644 index 0000000000..a15b351e39 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ApiKeyToApiKeyDtoMapper.java @@ -0,0 +1,51 @@ +/* + * 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; + +import static de.otto.edison.hal.Link.link; + +@Mapper +public abstract class ApiKeyToApiKeyDtoMapper { + + @Inject + private ResourceLinks resourceLinks; + + abstract ApiKeyDto map(ApiKey key); + + @ObjectFactory + ApiKeyDto createDto(ApiKey key) { + Links.Builder links = Links.linkingTo() + .self(resourceLinks.apiKey().self(key.getId())) + .single(link("delete", resourceLinks.apiKey().delete(key.getId()))); + return new ApiKeyDto(links.build()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java index f289bc917e..752c7dfbd4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.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 de.otto.edison.hal.Embedded; @@ -61,8 +61,10 @@ public abstract class BrowserResultToFileObjectDtoMapper extends BaseFileObjectD @Override void applyEnrichers(Links.Builder links, Embedded.Builder embeddedBuilder, NamespaceAndName namespaceAndName, BrowserResult browserResult, FileObject fileObject) { EdisonHalAppender appender = new EdisonHalAppender(links, embeddedBuilder); - // we call enrichers, which are only responsible for top level browseresults - applyEnrichers(appender, browserResult, namespaceAndName); + if (browserResult.getFile().equals(fileObject)) { + // we call enrichers, which are only responsible for top level browseresults + applyEnrichers(appender, browserResult, namespaceAndName); + } // we call enrichers, which are responsible for all file object top level browse result and its children applyEnrichers(appender, fileObject, namespaceAndName, browserResult, browserResult.getRevision()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index eb54d90684..f4638ef3d9 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -86,5 +86,7 @@ public class MapperModule extends AbstractModule { bind(ScmPathInfoStore.class).in(ServletScopes.REQUEST); bind(PluginDtoMapper.class).to(Mappers.getMapperClass(PluginDtoMapper.class)); + + bind(ApiKeyToApiKeyDtoMapper.class).to(Mappers.getMapperClass(ApiKeyToApiKeyDtoMapper.class)); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java index 705573e3c3..2101ba2ffb 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java @@ -94,6 +94,9 @@ public class MeDtoFactory extends HalAppenderMapper { if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) { linksBuilder.single(link("password", resourceLinks.me().passwordChange())); } + if (UserPermissions.changeApiKeys(user).isPermitted()) { + linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self())); + } Embedded.Builder embeddedBuilder = embeddedBuilder(); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java index 9393b6c9d0..d93acf0159 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import io.swagger.v3.oas.annotations.OpenAPIDefinition; @@ -35,6 +35,7 @@ import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.inject.Provider; import javax.validation.Valid; import javax.ws.rs.Consumes; import javax.ws.rs.GET; @@ -61,11 +62,14 @@ public class MeResource { private final UserManager userManager; private final PasswordService passwordService; + private final Provider apiKeyResource; + @Inject - public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) { + public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider apiKeyResource) { this.meDtoFactory = meDtoFactory; this.userManager = userManager; this.passwordService = passwordService; + this.apiKeyResource = apiKeyResource; } /** @@ -118,4 +122,9 @@ public class MeResource { ); return Response.noContent().build(); } + + @Path("api_keys") + public ApiKeyResource apiKeys() { + return apiKeyResource.get(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 546b6c8b52..684978b0ab 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -204,6 +204,46 @@ 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(); + } + + String create() { + return collectionLinkBuilder.method("apiKeys").parameters().method("create").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(); + } + + String delete(String id) { + return apiKeyLinkBuilder.method("apiKeys").parameters().method("delete").parameters(id).href(); + } + } + UserCollectionLinks userCollection() { return new UserCollectionLinks(scmPathInfoStore.get()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java index fa6ff90615..ba5e027bf4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import io.swagger.v3.oas.annotations.Operation; @@ -131,7 +131,7 @@ public class UserResource { * * Note: This method requires "user" privilege. * - * @param name name of the user to be modified + * @param name name of the user to be modified * @param user user object to modify */ @PUT diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java index cfa5b10f6e..985c53ddbd 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmSecurityModule.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.lifecycle.modules; //~--- non-JDK imports -------------------------------------------------------- @@ -33,6 +33,7 @@ import org.apache.shiro.authc.credential.DefaultPasswordService; import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.pam.AuthenticationStrategy; import org.apache.shiro.authc.pam.ModularRealmAuthenticator; +import org.apache.shiro.authz.permission.PermissionResolver; import org.apache.shiro.crypto.hash.DefaultHashService; import org.apache.shiro.guice.web.ShiroWebModule; import org.apache.shiro.realm.Realm; @@ -48,6 +49,7 @@ import javax.servlet.ServletContext; import org.apache.shiro.mgt.RememberMeManager; import sonia.scm.security.DisabledRememberMeManager; import sonia.scm.security.ScmAtLeastOneSuccessfulStrategy; +import sonia.scm.security.ScmPermissionResolver; /** * @@ -94,7 +96,7 @@ public class ScmSecurityModule extends ShiroWebModule // expose password service to global injector expose(PasswordService.class); - + // disable remember me cookie generation bind(RememberMeManager.class).to(DisabledRememberMeManager.class); @@ -102,6 +104,7 @@ public class ScmSecurityModule extends ShiroWebModule bind(ModularRealmAuthenticator.class); bind(Authenticator.class).to(ModularRealmAuthenticator.class); bind(AuthenticationStrategy.class).to(ScmAtLeastOneSuccessfulStrategy.class); + bind(PermissionResolver.class).to(ScmPermissionResolver.class); // bind realm for (Class realm : extensionProcessor.byExtensionPoint(Realm.class)) @@ -116,7 +119,7 @@ public class ScmSecurityModule extends ShiroWebModule // disable access to mustache resources addFilterChain("/**.mustache", filterConfig(ROLES, "nobody")); - + // disable session addFilterChain("/**", NO_SESSION_CREATION); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java new file mode 100644 index 0000000000..f2d8bfd046 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKey.java @@ -0,0 +1,48 @@ +/* + * 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.security; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.Instant; + +@Getter +@AllArgsConstructor +public class ApiKey { + private final String id; + private final String displayName; + private final String permissionRole; + private final Instant created; + + ApiKey(ApiKeyWithPassphrase apiKeyWithPassphrase) { + this( + apiKeyWithPassphrase.getId(), + apiKeyWithPassphrase.getDisplayName(), + apiKeyWithPassphrase.getPermissionRole(), + apiKeyWithPassphrase.getCreated() + ); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyCollection.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyCollection.java new file mode 100644 index 0000000000..1441e8af21 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyCollection.java @@ -0,0 +1,69 @@ +/* + * 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.security; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.apache.commons.collections.CollectionUtils; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.ArrayList; +import java.util.Collection; +import java.util.function.Predicate; + +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "keys") +class ApiKeyCollection { + @XmlElement(name = "key") + private Collection keys; + + public ApiKeyCollection add(ApiKeyWithPassphrase key) { + Collection newKeys; + if (CollectionUtils.isEmpty(keys)) { + newKeys = singletonList(key); + } else { + newKeys = new ArrayList<>(keys.size() + 1); + newKeys.addAll(keys); + newKeys.add(key); + } + return new ApiKeyCollection(newKeys); + } + + public ApiKeyCollection remove(Predicate predicate) { + Collection newKeys = keys.stream().filter(key -> !predicate.test(key)).collect(toList()); + return new ApiKeyCollection(newKeys); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java new file mode 100644 index 0000000000..ed2954eb32 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyRealm.java @@ -0,0 +1,111 @@ +/* + * 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.security; + +import org.apache.shiro.authc.AuthenticationInfo; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.realm.AuthenticatingRealm; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.RepositoryRole; +import sonia.scm.repository.RepositoryRoleManager; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static com.google.common.base.Preconditions.checkArgument; + +@Singleton +@Extension +public class ApiKeyRealm extends AuthenticatingRealm { + + private final ApiKeyService apiKeyService; + private final DAORealmHelper helper; + private final RepositoryRoleManager repositoryRoleManager; + + @Inject + public ApiKeyRealm(ApiKeyService apiKeyService, DAORealmHelperFactory helperFactory, RepositoryRoleManager repositoryRoleManager) { + this.apiKeyService = apiKeyService; + this.helper = helperFactory.create("ApiTokenRealm"); + this.repositoryRoleManager = repositoryRoleManager; + setAuthenticationTokenClass(BearerToken.class); + setCredentialsMatcher(new AllowAllCredentialsMatcher()); + } + + @Override + public boolean supports(AuthenticationToken token) { + return token instanceof UsernamePasswordToken || token instanceof BearerToken; + } + + @Override + protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { + checkArgument( + token instanceof BearerToken || token instanceof UsernamePasswordToken, + "%s is required", BearerToken.class); + String password = getPassword(token); + ApiKeyService.CheckResult check = apiKeyService.check(password); + return buildAuthenticationInfo(token, check); + } + + private AuthenticationInfo buildAuthenticationInfo(AuthenticationToken token, ApiKeyService.CheckResult check) { + RepositoryRole repositoryRole = determineRole(check); + Scope scope = createScope(repositoryRole); + return helper + .authenticationInfoBuilder(check.getUser()) + .withSessionId(getPrincipal(token)) + .withScope(scope) + .build(); + } + + private String getPassword(AuthenticationToken token) { + if (token instanceof BearerToken) { + return ((BearerToken) token).getCredentials(); + } else { + return new String(((UsernamePasswordToken) token).getPassword()); + } + } + + private RepositoryRole determineRole(ApiKeyService.CheckResult check) { + RepositoryRole repositoryRole = repositoryRoleManager.get(check.getPermissionRole()); + if (repositoryRole == null) { + throw new AuthorizationException("api key has unknown role: " + check.getPermissionRole()); + } + return repositoryRole; + } + + private Scope createScope(RepositoryRole repositoryRole) { + return Scope.valueOf("repository:" + String.join(",", repositoryRole.getVerbs()) + ":*"); + } + + private SessionId getPrincipal(AuthenticationToken token) { + if (token instanceof BearerToken) { + return ((BearerToken) token).getPrincipal(); + } else { + return SessionId.valueOf((token.getPrincipal()).toString()); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java new file mode 100644 index 0000000000..f0673380c3 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyService.java @@ -0,0 +1,219 @@ +/* + * 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.security; + +import com.github.legman.Subscribe; +import com.google.common.util.concurrent.Striped; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.shiro.authc.credential.PasswordService; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.util.ThreadContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.ContextEntry; +import sonia.scm.HandlerEventType; +import sonia.scm.store.DataStore; +import sonia.scm.store.DataStoreFactory; +import sonia.scm.user.UserEvent; +import sonia.scm.user.UserPermissions; + +import javax.inject.Inject; +import java.security.SecureRandom; +import java.util.Collection; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static java.time.Instant.now; +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; +import static org.apache.commons.lang.RandomStringUtils.random; +import static sonia.scm.AlreadyExistsException.alreadyExists; + +public class ApiKeyService { + + private static final Logger LOG = LoggerFactory.getLogger(ApiKeyService.class); + private static final int PASSPHRASE_LENGTH = 20; + + private final DataStore store; + private final PasswordService passwordService; + private final KeyGenerator keyGenerator; + private final Supplier passphraseGenerator; + private final ApiKeyTokenHandler tokenHandler; + + private final Striped locks = Striped.readWriteLock(10); + + @Inject + ApiKeyService(DataStoreFactory storeFactory, KeyGenerator keyGenerator, PasswordService passwordService, ApiKeyTokenHandler tokenHandler) { + this(storeFactory, passwordService, keyGenerator, tokenHandler, () -> random(PASSPHRASE_LENGTH, 0, 0, true, true, null, new SecureRandom())); + } + + ApiKeyService(DataStoreFactory storeFactory, PasswordService passwordService, KeyGenerator keyGenerator, ApiKeyTokenHandler tokenHandler, Supplier passphraseGenerator) { + this.store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build(); + this.passwordService = passwordService; + this.keyGenerator = keyGenerator; + this.tokenHandler = tokenHandler; + this.passphraseGenerator = passphraseGenerator; + } + + public CreationResult createNewKey(String name, String permissionRole) { + String user = currentUser(); + UserPermissions.changeApiKeys(user).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); + return null; + }); + String token = tokenHandler.createToken(user, new ApiKey(key), passphrase); + LOG.info("created new api key for user {} with role {}", user, permissionRole); + return new CreationResult(token, id); + } + + public void persistKey(String name, String user, ApiKeyWithPassphrase key) { + if (containsName(user, name)) { + throw alreadyExists(ContextEntry.ContextBuilder.entity(ApiKeyWithPassphrase.class, name)); + } + ApiKeyCollection apiKeyCollection = store.getOptional(user).orElse(new ApiKeyCollection(emptyList())); + ApiKeyCollection newApiKeyCollection = apiKeyCollection.add(key); + store.put(user, newApiKeyCollection); + } + + public void remove(String id) { + String user = currentUser(); + UserPermissions.changeApiKeys(user).check(); + doSynchronized(user, true, () -> { + if (!containsId(user, id)) { + return null; + } + store.getOptional(user).ifPresent( + apiKeyCollection -> { + ApiKeyCollection newApiKeyCollection = apiKeyCollection.remove(key -> id.equals(key.getId())); + store.put(user, newApiKeyCollection); + LOG.info("removed api key for user {}", user); + } + ); + return null; + }); + } + + CheckResult check(String tokenAsString) { + return check(tokenHandler.readToken(tokenAsString) + .orElseThrow(AuthorizationException::new)); + } + + private CheckResult check(ApiKeyTokenHandler.Token token) { + return check(token.getUser(), token.getApiKeyId(), token.getPassphrase()); + } + + CheckResult check(String user, String id, String passphrase) { + return doSynchronized(user, false, () -> store + .get(user) + .getKeys() + .stream() + .filter(key -> key.getId().equals(id)) + .filter(key -> passwordsMatch(user, passphrase, key)) + .map(ApiKeyWithPassphrase::getPermissionRole) + .map(role -> new CheckResult(user, role)) + .findAny() + .orElseThrow(AuthorizationException::new)); + } + + private boolean passwordsMatch(String user, String passphrase, ApiKeyWithPassphrase key) { + boolean result = passwordService.passwordsMatch(passphrase, key.getPassphrase()); + if (!result) { + // this can only happen with a forged api key, so it may be relevant enough to issue a warning + LOG.warn("got invalid api key for user {} with key id {}", user, key.getId()); + } + return result; + } + + public Collection getKeys() { + return store.getOptional(currentUser()) + .map(ApiKeyCollection::getKeys) + .map(Collection::stream) + .orElse(Stream.empty()) + .map(ApiKey::new) + .collect(toList()); + } + + private String currentUser() { + 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.getDisplayName().equals(name)); + } + + private T doSynchronized(String user, boolean write, Supplier callback) { + final ReadWriteLock lockFactory = locks.get(user); + Lock lock = write ? lockFactory.writeLock() : lockFactory.readLock(); + lock.lock(); + try { + return callback.get(); + } finally { + lock.unlock(); + } + } + + @Subscribe + public void cleanupForDeletedUser(UserEvent userEvent) { + if (userEvent.getEventType() == HandlerEventType.DELETE) { + store.remove(userEvent.getItem().getId()); + } + } + + @Getter + @AllArgsConstructor + public static class CreationResult { + private final String token; + private final String id; + } + + @Getter + @AllArgsConstructor + public static class CheckResult { + private final String user; + private final String permissionRole; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyTokenHandler.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyTokenHandler.java new file mode 100644 index 0000000000..34ee392060 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyTokenHandler.java @@ -0,0 +1,84 @@ +/* + * 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.security; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.io.Decoder; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.io.DecodingException; +import io.jsonwebtoken.io.Encoder; +import io.jsonwebtoken.io.Encoders; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Optional; + +import static java.util.Optional.empty; +import static java.util.Optional.of; + +class ApiKeyTokenHandler { + + private static final Encoder encoder = Encoders.BASE64URL; + private static final Decoder decoder = Decoders.BASE64URL; + private static final Logger LOG = LoggerFactory.getLogger(ApiKeyTokenHandler.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + String createToken(String user, ApiKey apiKey, String passphrase) { + final Token token = new Token(apiKey.getId(), user, passphrase); + try { + return encoder.encode(OBJECT_MAPPER.writeValueAsBytes(token)); + } catch (JsonProcessingException e) { + LOG.error("could not serialize token"); + throw new TokenSerializationException(e); + } + } + + Optional readToken(String token) { + try { + return of(OBJECT_MAPPER.readValue(decoder.decode(token), Token.class)); + } catch (IOException | DecodingException e) { + LOG.warn("error reading api token", e); + return empty(); + } + } + + @AllArgsConstructor + @Getter + public static class Token { + private final String apiKeyId; + private final String user; + private final String passphrase; + } + + private static class TokenSerializationException extends RuntimeException { + public TokenSerializationException(Throwable cause) { + super(cause); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java new file mode 100644 index 0000000000..ca56c23693 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ApiKeyWithPassphrase.java @@ -0,0 +1,52 @@ +/* + * 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.security; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sonia.scm.xml.XmlInstantAdapter; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.time.Instant; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@XmlAccessorType(XmlAccessType.FIELD) +class ApiKeyWithPassphrase { + private String id; + @XmlElement(name = "display-name") + private String displayName; + @XmlElement(name = "permission-role") + private String permissionRole; + private String passphrase; + @XmlJavaTypeAdapter(XmlInstantAdapter.class) + private Instant created; +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index ddc65a8c0d..b924769b6c 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -250,6 +250,7 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector builder.add(getUserAutocompletePermission()); builder.add(getGroupAutocompletePermission()); builder.add(getChangeOwnPasswordPermission(user)); + builder.add(getApiKeyPermission(user)); } SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(ImmutableSet.of(Role.USER)); @@ -266,6 +267,10 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector return UserPermissions.changePassword(user).asShiroString(); } + private String getApiKeyPermission(User user) { + return UserPermissions.changeApiKeys(user).asShiroString(); + } + private String getUserAutocompletePermission() { return UserPermissions.autocomplete().asShiroString(); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java index d7306a6247..864aac3c98 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultRealm.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.security; //~--- non-JDK imports -------------------------------------------------------- @@ -57,9 +57,9 @@ import java.util.Set; @Singleton public class DefaultRealm extends AuthorizingRealm { - + private static final String SEPARATOR = System.getProperty("line.separator", "\n"); - + /** * the logger for DefaultRealm */ @@ -68,6 +68,7 @@ public class DefaultRealm extends AuthorizingRealm /** Field description */ @VisibleForTesting static final String REALM = "DefaultRealm"; + private final ScmPermissionResolver permissionResolver; //~--- constructors --------------------------------------------------------- @@ -90,11 +91,18 @@ public class DefaultRealm extends AuthorizingRealm matcher.setPasswordService(service); setCredentialsMatcher(helper.wrapCredentialsMatcher(matcher)); setAuthenticationTokenClass(UsernamePasswordToken.class); + permissionResolver = new ScmPermissionResolver(); + setPermissionResolver(permissionResolver); // we cache in the AuthorizationCollector setCachingEnabled(false); } + @Override + public ScmPermissionResolver getPermissionResolver() { + return permissionResolver; + } + //~--- methods -------------------------------------------------------------- /** @@ -168,13 +176,13 @@ public class DefaultRealm extends AuthorizingRealm private void log( PrincipalCollection collection, AuthorizationInfo original, AuthorizationInfo filtered ) { StringBuilder buffer = new StringBuilder("authorization summary: "); - + buffer.append(SEPARATOR).append("username : ").append(collection.getPrimaryPrincipal()); buffer.append(SEPARATOR).append("roles : "); - append(buffer, original.getRoles()); + append(buffer, original.getRoles()); buffer.append(SEPARATOR).append("scope : "); - append(buffer, collection.oneByType(Scope.class)); - + append(buffer, collection.oneByType(Scope.class)); + if ( filtered != null ) { buffer.append(SEPARATOR).append("permissions (filtered by scope): "); append(buffer, filtered); @@ -183,21 +191,21 @@ public class DefaultRealm extends AuthorizingRealm buffer.append(SEPARATOR).append("permissions: "); } append(buffer, original); - + LOG.trace(buffer.toString()); } - + private void append(StringBuilder buffer, AuthorizationInfo authz) { append(buffer, authz.getStringPermissions()); - append(buffer, authz.getObjectPermissions()); + append(buffer, authz.getObjectPermissions()); } - + private void append(StringBuilder buffer, Iterable iterable){ if (iterable != null){ for ( Object item : iterable ) { buffer.append(SEPARATOR).append(" - ").append(item); - } + } } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/ScmPermissionResolver.java b/scm-webapp/src/main/java/sonia/scm/security/ScmPermissionResolver.java new file mode 100644 index 0000000000..f9689b5d7a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ScmPermissionResolver.java @@ -0,0 +1,34 @@ +/* + * 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.security; + +import org.apache.shiro.authz.permission.PermissionResolver; + +public class ScmPermissionResolver implements PermissionResolver { + @Override + public ScmWildcardPermission resolvePermission(String permissionString) { + return new ScmWildcardPermission(permissionString); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/ScmWildcardPermission.java b/scm-webapp/src/main/java/sonia/scm/security/ScmWildcardPermission.java new file mode 100644 index 0000000000..6b46cd15a0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/ScmWildcardPermission.java @@ -0,0 +1,154 @@ +/* + * 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.security; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.shiro.authz.permission.WildcardPermission; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static java.util.Collections.singleton; +import static java.util.Optional.empty; +import static java.util.Optional.of; + +public class ScmWildcardPermission extends WildcardPermission { + public ScmWildcardPermission(String permissionString) { + super(permissionString); + } + + /** + * Limits this permission to the given scope. This will result in a collection of new permissions. This + * collection can be empty (but this will not return null). Three examples: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
This permissionScopeResulting permission(s)
repository:*:42repository:read,pull:*repository:read,pull:42
repository:read:*repository:*:42, repository:*:1337repository:read:42, repository:read:1337
user:*:*repository:read,pull:*empty
+ * @param scope The scope this permission should be limited to. + * @return A collection with the resulting permissions (mind that this can be empty, but not null). + */ + Collection limit(Scope scope) { + Collection result = new ArrayList<>(); + for (String s : scope) { + limit(s).ifPresent(result::add); + } + return result; + } + + /** + * Limits this permission to a scope with a single permission. For examples see {@link #limit(String)}. + * @param scope The single scope. + * @return An {@link Optional} with the resulting permission if there was a overlap between this and the scope, or + * an empty {@link Optional} otherwise. + */ + Optional limit(String scope) { + return limit(new ScmWildcardPermission(scope)); + } + + /** + * Limits this permission to a scope with a single permission. For examples see {@link #limit(String)}. + * @param scope The single scope. + * @return An {@link Optional} with the resulting permission if there was a overlap between this and the scope, or + * an empty {@link Optional} otherwise. + */ + Optional limit(ScmWildcardPermission scope) { + // if one permission is a subset of the other, we can return the smaller one. + if (this.implies(scope)) { + return of(scope); + } + if (scope.implies(this)) { + return of(this); + } + + // First we check, whether the subjects are the same. We do not use permissions with different subjects, so we + // either have both this the same subject, or we have no overlap. + final List> theseParts = getParts(); + final List> scopeParts = scope.getParts(); + + if (!getEntries(theseParts, 0).equals(getEntries(scopeParts, 0))) { + return empty(); + } + + String subject = getEntries(scopeParts, 0).iterator().next(); + + // Now we create the intersections of verbs and ids to create the resulting permission + // (if not one of the resulting sets is empty) + Collection verbs = intersect(theseParts, scopeParts, 1); + Collection ids = intersect(theseParts, scopeParts, 2); + + if (verbs.isEmpty() || ids.isEmpty()) { + return empty(); + } + + return of(new ScmWildcardPermission(subject + ":" + String.join(",", verbs) + ":" + String.join(",", ids))); + } + + private Collection intersect(List> theseParts, List> scopeParts, int position) { + final Set theseEntries = getEntries(theseParts, position); + final Set scopeEntries = getEntries(scopeParts, position); + if (isWildcard(theseEntries)) { + return scopeEntries; + } + if (isWildcard(scopeEntries)) { + return theseEntries; + } + return CollectionUtils.intersection(theseEntries, scopeEntries); + } + + /** + * Handles "shortened" permissions like repository:read that should be repository:read:*. + */ + private Set getEntries(List> theseParts, int position) { + if (position >= theseParts.size()) { + return singleton(WILDCARD_TOKEN); + } + return theseParts.get(position); + } + + private boolean isWildcard(Set entries) { + return entries.size() == 1 && entries.contains(WILDCARD_TOKEN); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java index 81207b06fa..8e4abbbd7d 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/Scopes.java +++ b/scm-webapp/src/main/java/sonia/scm/security/Scopes.java @@ -21,28 +21,26 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.google.common.collect.Collections2; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; +import org.apache.shiro.authz.AuthorizationInfo; +import org.apache.shiro.authz.Permission; +import org.apache.shiro.authz.SimpleAuthorizationInfo; + import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Predicate; import java.util.stream.Collectors; -import org.apache.shiro.authz.AuthorizationInfo; -import org.apache.shiro.authz.Permission; -import org.apache.shiro.authz.SimpleAuthorizationInfo; -import org.apache.shiro.authz.permission.PermissionResolver; /** * Util methods for {@link Scope}. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ @@ -50,16 +48,16 @@ public final class Scopes { /** Key of scope in the claims of a token **/ public final static String CLAIMS_KEY = "scope"; - + private Scopes() { } - + /** * Returns scope from a token claims. If the claims does not contain a scope object, the method will return an empty * scope. - * + * * @param claims token claims - * + * * @return scope of claims */ @SuppressWarnings("unchecked") @@ -70,11 +68,11 @@ public final class Scopes { } return scope; } - + /** - * Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not + * Adds a scope to a token claims. The method will add the scope to the claims, if the scope is non null and not * empty. - * + * * @param claims token claims * @param scope scope */ @@ -83,55 +81,51 @@ public final class Scopes { claims.put(CLAIMS_KEY, ImmutableSet.copyOf(scope)); } } - + /** - * Filter permissions from {@link AuthorizationInfo} by scope values. Only permission definitions from the scope will - * be returned and only if a permission from the {@link AuthorizationInfo} implies the requested scope permission. - * + * Limit permissions from {@link AuthorizationInfo} by scope values. Permission definitions from the + * {@link AuthorizationInfo} will be returned, if a permission from the scope implies the original permission. + * If a permission from the {@link AuthorizationInfo} exceeds the permissions defined by the scope, it will + * be reduced. If the latter computation results in an empty permission, it will be omitted. + * * @param resolver permission resolver * @param authz authorization info * @param scope scope - * - * @return filtered {@link AuthorizationInfo} + * + * @return limited {@link AuthorizationInfo} */ - public static AuthorizationInfo filter(PermissionResolver resolver, AuthorizationInfo authz, Scope scope) { + public static AuthorizationInfo filter(ScmPermissionResolver resolver, AuthorizationInfo authz, Scope scope) { List authzPermissions = authzPermissions(resolver, authz); - Predicate predicate = implies(authzPermissions); - Set filteredPermissions = resolve(resolver, ImmutableList.copyOf(scope)) + Set filteredPermissions = authzPermissions .stream() - .filter(predicate) + .map(p -> asScmWildcardPermission(p)) + .map(p -> p.limit(scope)) + .flatMap(Collection::stream) .collect(Collectors.toSet()); - + Set roles = ImmutableSet.copyOf(nullToEmpty(authz.getRoles())); SimpleAuthorizationInfo authzFiltered = new SimpleAuthorizationInfo(roles); authzFiltered.setObjectPermissions(filteredPermissions); return authzFiltered; } - + + public static ScmWildcardPermission asScmWildcardPermission(Permission p) { + return p instanceof ScmWildcardPermission ? (ScmWildcardPermission) p : new ScmWildcardPermission(p.toString()); + } + private static Collection nullToEmpty(Collection collection) { return collection != null ? collection : Collections.emptySet(); } - - private static Collection resolve(PermissionResolver resolver, Collection permissions) { + + private static Collection resolve(ScmPermissionResolver resolver, Collection permissions) { return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission); } - - private static Predicate implies(Iterable authzPermissions){ - return (scopePermission) -> { - for ( Permission authzPermission : authzPermissions ) { - if (authzPermission.implies(scopePermission)) { - return true; - } - } - return false; - }; - } - - private static List authzPermissions(PermissionResolver resolver, AuthorizationInfo authz){ + + private static List authzPermissions(ScmPermissionResolver resolver, AuthorizationInfo authz){ List authzPermissions = Lists.newArrayList(); authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions())); authzPermissions.addAll(resolve(resolver, authz.getStringPermissions())); return authzPermissions; } - + } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java index d6709e5f49..953f4638ad 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; @@ -41,6 +41,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.ContextEntry; import sonia.scm.group.GroupCollector; +import sonia.scm.security.ApiKey; +import sonia.scm.security.ApiKeyService; import sonia.scm.user.InvalidPasswordException; import sonia.scm.user.User; import sonia.scm.user.UserManager; @@ -52,13 +54,17 @@ import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import static com.google.inject.util.Providers.of; +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.junit.Assert.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -87,8 +93,13 @@ public class MeResourceTest { @Mock private UserManager userManager; + @Mock + private ApiKeyService apiKeyService; + @InjectMocks private MeDtoFactory meDtoFactory; + @InjectMocks + private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; private ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); @@ -96,6 +107,8 @@ public class MeResourceTest { private PasswordService passwordService; private User originalUser; + private MockHttpResponse response = new MockHttpResponse(); + @Before public void prepareEnvironment() { initMocks(this); @@ -106,7 +119,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, resourceLinks); + 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 +133,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/api_keys\"}"); } private void applyUserToSubject(User user) { @@ -149,7 +164,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 +188,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 +203,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 +218,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 +227,80 @@ public class MeResourceTest { assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus()); } + @Test + public void shouldGetAllApiKeys() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.getKeys()) + .thenReturn(asList( + new ApiKey("1", "key 1", "READ", now()), + new ApiKey("2", "key 2", "WRITE", now()))); + + MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "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/me/api_keys\"}"); + assertThat(response.getContentAsString()).contains("\"create\":{\"href\":\"/v2/me/api_keys\"}"); + } + + @Test + public void shouldGetSingleApiKey() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.getKeys()) + .thenReturn(asList( + new ApiKey("1", "key 1", "READ", now()), + new ApiKey("2", "key 2", "WRITE", now()))); + + MockHttpRequest request = MockHttpRequest.get("/" + MeResource.ME_PATH_V2 + "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/me/api_keys/1\"}"); + assertThat(response.getContentAsString()).contains("\"delete\":{\"href\":\"/v2/me/api_keys/1\"}"); + } + + @Test + public void shouldCreateNewApiKey() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1")); + + final MockHttpRequest request = MockHttpRequest + .post("/" + MeResource.ME_PATH_V2 + "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/me/api_keys/1")); + } + + @Test + public void shouldIgnoreInvalidNewApiKey() throws URISyntaxException, UnsupportedEncodingException { + when(apiKeyService.createNewKey("guide", "READ")).thenReturn(new ApiKeyService.CreationResult("abc", "1")); + + final MockHttpRequest request = MockHttpRequest + .post("/" + MeResource.ME_PATH_V2 + "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("/" + MeResource.ME_PATH_V2 + "api_keys/1"); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + verify(apiKeyService).remove("1"); + } private User createDummyUser(String name) { User user = new User(); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index e7c21f5556..2bec6f3166 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -80,6 +80,8 @@ public class ResourceLinksMock { lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo)); + lenient().when(resourceLinks.apiKeyCollection()).thenReturn(new ResourceLinks.ApiKeyCollectionLinks(pathInfo)); + lenient().when(resourceLinks.apiKey()).thenReturn(new ResourceLinks.ApiKeyLinks(pathInfo)); return resourceLinks; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java index 2cec89f7ec..1609bc0065 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.ShiroRule; diff --git a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java new file mode 100644 index 0000000000..48a014dfb2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyRealmTest.java @@ -0,0 +1,105 @@ +/* + * 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.security; + +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authz.AuthorizationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.RepositoryRole; +import sonia.scm.repository.RepositoryRoleManager; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static sonia.scm.security.BearerToken.valueOf; + +@ExtendWith(MockitoExtension.class) +class ApiKeyRealmTest { + + @Mock + ApiKeyService apiKeyService; + @Mock + DAORealmHelperFactory helperFactory; + @Mock + DAORealmHelper helper; + @Mock(answer = Answers.RETURNS_SELF) + DAORealmHelper.AuthenticationInfoBuilder authenticationInfoBuilder; + @Mock + RepositoryRoleManager repositoryRoleManager; + + ApiKeyRealm realm; + + @BeforeEach + void initRealmHelper() { + lenient().when(helperFactory.create("ApiTokenRealm")).thenReturn(helper); + lenient().when(helper.authenticationInfoBuilder(any())).thenReturn(authenticationInfoBuilder); + realm = new ApiKeyRealm(apiKeyService, helperFactory, repositoryRoleManager); + } + + @Test + void shouldCreateAuthenticationWithScope() { + when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ")); + when(repositoryRoleManager.get("READ")).thenReturn(new RepositoryRole("guide", singleton("read"), "system")); + + realm.doGetAuthenticationInfo(valueOf("towel")); + + verify(helper).authenticationInfoBuilder("ford"); + verifyScopeSet("repository:read:*"); + verify(authenticationInfoBuilder).withSessionId(null); + } + + @Test + void shouldFailWithoutBearerToken() { + AuthenticationToken otherToken = mock(AuthenticationToken.class); + assertThrows(IllegalArgumentException.class, () -> realm.doGetAuthenticationInfo(otherToken)); + } + + @Test + void shouldFailWithUnknownRole() { + when(apiKeyService.check("towel")).thenReturn(new ApiKeyService.CheckResult("ford", "READ")); + when(repositoryRoleManager.get("READ")).thenReturn(null); + + BearerToken token = valueOf("towel"); + assertThrows(AuthorizationException.class, () -> realm.doGetAuthenticationInfo(token)); + } + + void verifyScopeSet(String... permissions) { + verify(authenticationInfoBuilder).withScope(argThat(scope -> { + assertThat(scope).containsExactly(permissions); + return true; + })); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java new file mode 100644 index 0000000000..adb7d5b0ba --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyServiceTest.java @@ -0,0 +1,181 @@ +/* + * 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.security; + +import org.apache.shiro.authc.credential.PasswordService; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.PrincipalCollection; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import sonia.scm.AlreadyExistsException; +import sonia.scm.HandlerEventType; +import sonia.scm.store.DataStore; +import sonia.scm.store.DataStoreFactory; +import sonia.scm.store.InMemoryDataStore; +import sonia.scm.store.InMemoryDataStoreFactory; +import sonia.scm.user.User; +import sonia.scm.user.UserEvent; + +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApiKeyServiceTest { + + int nextKey = 1; + int nextId = 1; + + PasswordService passwordService = mock(PasswordService.class); + Supplier passphraseGenerator = () -> Integer.toString(nextKey++); + KeyGenerator keyGenerator = () -> Integer.toString(nextId++); + ApiKeyTokenHandler tokenHandler = new ApiKeyTokenHandler(); + DataStoreFactory storeFactory = new InMemoryDataStoreFactory(new InMemoryDataStore()); + DataStore store = storeFactory.withType(ApiKeyCollection.class).withName("apiKeys").build(); + ApiKeyService service = new ApiKeyService(storeFactory, passwordService, keyGenerator, tokenHandler, passphraseGenerator); + + @BeforeEach + void mockPasswordService() { + when(passwordService.encryptPassword(any())) + .thenAnswer(invocationOnMock -> invocationOnMock.getArgument(0) + "-hashed"); + when(passwordService.passwordsMatch(any(), any())) + .thenAnswer(invocationOnMock -> invocationOnMock.getArgument(1, String.class).startsWith(invocationOnMock.getArgument(0))); + } + + @Nested + class WithLoggedInUser { + @BeforeEach + void mockUser() { + final Subject subject = mock(Subject.class); + ThreadContext.bind(subject); + final PrincipalCollection principalCollection = mock(PrincipalCollection.class); + when(subject.getPrincipals()).thenReturn(principalCollection); + when(principalCollection.getPrimaryPrincipal()).thenReturn("dent"); + } + + @AfterEach + void unbindSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldCreateNewKeyAndStoreItHashed() { + service.createNewKey("1", "READ"); + + ApiKeyCollection apiKeys = store.get("dent"); + + assertThat(apiKeys.getKeys()).hasSize(1); + ApiKeyWithPassphrase key = apiKeys.getKeys().iterator().next(); + assertThat(key.getPermissionRole()).isEqualTo("READ"); + assertThat(key.getPassphrase()).isEqualTo("1-hashed"); + + ApiKeyService.CheckResult role = service.check("dent", "1", "1-hashed"); + + assertThat(role).extracting("permissionRole").isEqualTo("READ"); + } + + @Test + void shouldReturnRoleForKey() { + String newKey = service.createNewKey("1", "READ").getToken(); + + ApiKeyService.CheckResult role = service.check(newKey); + + assertThat(role).extracting("permissionRole").isEqualTo("READ"); + } + + @Test + void shouldHandleNewUser() { + assertThat(service.getKeys()).isEmpty(); + } + + @Test + void shouldNotReturnAnythingWithWrongKey() { + service.createNewKey("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"); + + ApiKeyCollection apiKeys = store.get("dent"); + + assertThat(apiKeys.getKeys()).hasSize(2); + + assertThat(service.check(firstKey.getToken())).extracting("permissionRole").isEqualTo("READ"); + assertThat(service.check(secondKey.getToken())).extracting("permissionRole").isEqualTo("WRITE"); + + assertThat(service.getKeys()).extracting("id") + .contains(firstKey.getId(), secondKey.getId()); + } + + @Test + void shouldRemoveKey() { + String firstKey = service.createNewKey("first", "READ").getToken(); + String secondKey = service.createNewKey("second", "WRITE").getToken(); + + service.remove("1"); + + assertThrows(AuthorizationException.class, () -> service.check(firstKey)); + assertThat(service.check(secondKey)).extracting("permissionRole").isEqualTo("WRITE"); + } + + @Test + void shouldFailWhenAddingSameNameTwice() { + String firstKey = service.createNewKey("1", "READ").getToken(); + + assertThrows(AlreadyExistsException.class, () -> service.createNewKey("1", "WRITE")); + + assertThat(service.check(firstKey)).extracting("permissionRole").isEqualTo("READ"); + } + + @Test + void shouldIgnoreCorrectPassphraseWithWrongName() { + String firstKey = service.createNewKey("1", "READ").getToken(); + + assertThrows(AuthorizationException.class, () -> service.check("dent", "other", firstKey)); + } + + @Test + void shouldDeleteTokensWhenUserIsDeleted() { + service.createNewKey("1", "READ").getToken(); + + assertThat(store.get("dent").getKeys()).hasSize(1); + + service.cleanupForDeletedUser(new UserEvent(HandlerEventType.DELETE, new User("dent"))); + + assertThat(store.get("dent")).isNull(); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/ApiKeyTokenHandlerTest.java b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyTokenHandlerTest.java new file mode 100644 index 0000000000..390d5e6ba2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ApiKeyTokenHandlerTest.java @@ -0,0 +1,64 @@ +/* + * 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.security; + +import io.jsonwebtoken.io.Encoders; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static java.time.Instant.now; +import static org.assertj.core.api.Assertions.assertThat; + +class ApiKeyTokenHandlerTest { + + ApiKeyTokenHandler handler = new ApiKeyTokenHandler(); + + @Test + void shouldSerializeAndDeserializeToken() { + String tokenString = handler.createToken("dent", new ApiKey("42", "hg2g", "READ", now()), "some secret"); + + Optional token = handler.readToken(tokenString); + + assertThat(token).isNotEmpty(); + assertThat(token).get().extracting("user").isEqualTo("dent"); + assertThat(token).get().extracting("apiKeyId").isEqualTo("42"); + assertThat(token).get().extracting("passphrase").isEqualTo("some secret"); + } + + @Test + void shouldNotFailWithInvalidTokenEncoding() { + Optional token = handler.readToken("invalid token"); + + assertThat(token).isEmpty(); + } + + @Test + void shouldNotFailWithInvalidTokenContent() { + Optional token = handler.readToken(Encoders.BASE64URL.encode("{\"invalid\":\"token\"}".getBytes())); + + assertThat(token).isEmpty(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java index 30a6e42d10..93cce78932 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java @@ -167,8 +167,8 @@ public class DefaultAuthorizationCollectorTest { AuthorizationInfo authInfo = collector.collect(); assertThat(authInfo.getRoles(), Matchers.contains(Role.USER)); - assertThat(authInfo.getStringPermissions(), hasSize(4)); - assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian")); + assertThat(authInfo.getStringPermissions(), hasSize(5)); + assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:read:trillian", "user:changeApiKeys:trillian")); assertThat(authInfo.getObjectPermissions(), nullValue()); } @@ -212,7 +212,7 @@ public class DefaultAuthorizationCollectorTest { AuthorizationInfo authInfo = collector.collect(); assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); assertThat(authInfo.getObjectPermissions(), nullValue()); - assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian")); + assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian", "user:changeApiKeys:trillian")); } /** @@ -244,7 +244,7 @@ public class DefaultAuthorizationCollectorTest { AuthorizationInfo authInfo = collector.collect(); assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); assertThat(authInfo.getObjectPermissions(), nullValue()); - assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian")); + assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "repository:read,pull:one", "repository:read,pull,push:two", "user:read:trillian", "user:changeApiKeys:trillian")); } /** @@ -287,7 +287,8 @@ public class DefaultAuthorizationCollectorTest { "repository:user:one", "repository:system:one", "repository:group:two", - "user:read:trillian")); + "user:read:trillian", + "user:changeApiKeys:trillian")); } /** @@ -334,7 +335,7 @@ public class DefaultAuthorizationCollectorTest { AuthorizationInfo authInfo = collector.collect(); assertThat(authInfo.getRoles(), Matchers.containsInAnyOrder(Role.USER)); assertThat(authInfo.getObjectPermissions(), nullValue()); - assertThat(authInfo.getStringPermissions(), containsInAnyOrder("one:one", "two:two", "user:read:trillian", "user:autocomplete", "group:autocomplete", "user:changePassword:trillian")); + assertThat(authInfo.getStringPermissions(), containsInAnyOrder("one:one", "two:two", "user:read:trillian", "user:autocomplete", "group:autocomplete", "user:changePassword:trillian", "user:changeApiKeys:trillian")); } private void authenticate(User user, String group, String... groups) { diff --git a/scm-webapp/src/test/java/sonia/scm/security/ScmWildcardPermissionTest.java b/scm-webapp/src/test/java/sonia/scm/security/ScmWildcardPermissionTest.java new file mode 100644 index 0000000000..4977c26914 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/ScmWildcardPermissionTest.java @@ -0,0 +1,106 @@ +/* + * 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.security; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScmWildcardPermissionTest { + + @Test + void shouldEliminatePermissionsWithDifferentSubject() { + ScmWildcardPermission permission = new ScmWildcardPermission("user:write:*"); + + Optional limitedPermissions = permission.limit("repository:write:*"); + + assertThat(limitedPermissions).isEmpty(); + } + + @Test + void shouldReturnScopeIfPermissionImpliesScope() { + ScmWildcardPermission permission = new ScmWildcardPermission("*"); + + Optional limitedPermission = permission.limit("repository:read:42"); + + assertThat(limitedPermission).get().hasToString("repository:read:42"); + } + + @Test + void shouldReturnPermissionIfScopeImpliesPermission() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42"); + + Optional limitedPermission = permission.limit("repository:*:42"); + + assertThat(limitedPermission).get().hasToString("repository:read:42"); + } + + @Test + void shouldLimitExplicitParts() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:42,43,44"); + + Optional limitedPermission = permission.limit("repository:read,write,pull:42"); + + assertThat(limitedPermission).get().hasToString("repository:read,write:42"); + } + + @Test + void shouldDetectWildcard() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write:*"); + + Optional limitedPermission = permission.limit("repository:*:42"); + + assertThat(limitedPermission).get().hasToString("repository:read,write:42"); + } + + @Test + void shouldHandleMissingEntriesAsWildcard() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write"); + + Optional limitedPermission = permission.limit("repository:*:42"); + + assertThat(limitedPermission).get().hasToString("repository:read,write:42"); + } + + @Test + void shouldEliminateEmptyVerbs() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42"); + + Optional limitedPermission = permission.limit("repository:pull:42"); + + assertThat(limitedPermission).isEmpty(); + } + + @Test + void shouldEliminateEmptyId() { + ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42"); + + Optional limitedPermission = permission.limit("repository:read:23"); + + assertThat(limitedPermission).isEmpty(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java b/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java index b0a98fbf9e..22d09e4fc5 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/ScopesTest.java @@ -21,29 +21,32 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import com.google.common.collect.Collections2; import com.google.common.collect.Sets; -import java.util.Set; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.Permission; import org.apache.shiro.authz.SimpleAuthorizationInfo; -import org.apache.shiro.authz.permission.WildcardPermission; -import org.apache.shiro.authz.permission.WildcardPermissionResolver; import org.junit.Test; -import static org.junit.Assert.*; -import static org.hamcrest.Matchers.*; + +import java.util.Set; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; /** * Unit tests for {@link Scopes}. - * + * * @author Sebastian Sdorra */ public class ScopesTest { - private final WildcardPermissionResolver resolver = new WildcardPermissionResolver(); + private final ScmPermissionResolver resolver = new ScmPermissionResolver(); /** * Tests that filter keep roles. @@ -51,11 +54,11 @@ public class ScopesTest { @Test public void testFilterKeepRoles(){ AuthorizationInfo authz = authz("repository:read:123"); - + AuthorizationInfo filtered = Scopes.filter(resolver, authz, Scope.empty()); assertThat(filtered.getRoles(), containsInAnyOrder("unit", "test")); } - + /** * Tests filter with a simple allow. */ @@ -63,10 +66,18 @@ public class ScopesTest { public void testFilterSimpleAllow() { Scope scope = Scope.valueOf("repository:read:123"); AuthorizationInfo authz = authz("repository:*", "user:*:me"); - + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read:123"); } - + + @Test + public void testFilterIntersectingPermissions() { + Scope scope = Scope.valueOf("repository:read,write:*"); + AuthorizationInfo authz = authz("repository:*:123"); + + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:read,write:123"); + } + /** * Tests filter with a simple deny. */ @@ -74,12 +85,12 @@ public class ScopesTest { public void testFilterSimpleDeny() { Scope scope = Scope.valueOf("repository:read:123"); AuthorizationInfo authz = authz("user:*:me"); - + AuthorizationInfo filtered = Scopes.filter(resolver, authz, scope); assertThat(filtered.getStringPermissions(), is(nullValue())); assertThat(filtered.getObjectPermissions(), is(emptyCollectionOf(Permission.class))); } - + /** * Tests filter with a multiple scope entries. */ @@ -87,10 +98,10 @@ public class ScopesTest { public void testFilterMultiple() { Scope scope = Scope.valueOf("repo:read,modify:1", "repo:read:2", "repo:*:3", "repo:modify:4"); AuthorizationInfo authz = authz("repo:read:*"); - - assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:2"); + + assertPermissions(Scopes.filter(resolver, authz, scope), "repo:read:1", "repo:read:2", "repo:read:3"); } - + /** * Tests filter with admin permissions. */ @@ -98,10 +109,10 @@ public class ScopesTest { public void testFilterAdmin(){ Scope scope = Scope.valueOf("repository:*", "user:*:me"); AuthorizationInfo authz = authz("*"); - + assertPermissions(Scopes.filter(resolver, authz, scope), "repository:*", "user:*:me"); } - + /** * Tests filter with requested admin permissions from a non admin. */ @@ -109,29 +120,27 @@ public class ScopesTest { public void testFilterRequestAdmin(){ Scope scope = Scope.valueOf("*"); AuthorizationInfo authz = authz("repository:*"); - - assertThat( - Scopes.filter(resolver, authz, scope).getObjectPermissions(), - is(emptyCollectionOf(Permission.class)) - ); + + assertPermissions(Scopes.filter(resolver, authz, scope), + "repository:*"); } - + private void assertPermissions(AuthorizationInfo authz, Object... permissions) { assertThat(authz.getStringPermissions(), is(nullValue())); assertThat( - Collections2.transform(authz.getObjectPermissions(), Permission::toString), + Collections2.transform(authz.getObjectPermissions(), Permission::toString), containsInAnyOrder(permissions) ); } - + private AuthorizationInfo authz( String... values ) { SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(Sets.newHashSet("unit", "test")); Set permissions = Sets.newLinkedHashSet(); for ( String value : values ) { - permissions.add(new WildcardPermission(value)); + permissions.add(new ScmWildcardPermission(value)); } info.setObjectPermissions(permissions); return info; } -} \ No newline at end of file +}