Merge pull request #1359 from scm-manager/feature/api_keys

API keys
This commit is contained in:
eheimbuch
2020-10-07 08:43:45 +02:00
committed by GitHub
57 changed files with 2631 additions and 120 deletions

View File

@@ -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))

View File

@@ -4,3 +4,4 @@
- /user/user/
- /user/group/
- /user/admin/
- /user/profile/

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -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.

View File

@@ -12,6 +12,7 @@
- /user/user/
- /user/group/
- /user/admin/
- /user/profile/
- section: Administration
entries:

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -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.

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.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")

View File

@@ -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() {
}

View File

@@ -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;
}
}

View File

@@ -100,6 +100,7 @@ const Footer: FC<Props> = ({ me, version, links }) => {
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
{me?._links?.publicKeys && <NavLink to="/me/settings/publicKeys" label={t("profile.publicKeysNavLink")} />}
{me?._links?.apiKeys && <NavLink to="/me/settings/apiKeys" label={t("profile.apiKeysNavLink")} />}
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>

View File

@@ -71,6 +71,7 @@
"informationNavLink": "Information",
"changePasswordNavLink": "Passwort ändern",
"publicKeysNavLink": "Öffentliche Schlüssel",
"apiKeysNavLink": "API Schlüssel",
"settingsNavLink": "Einstellungen",
"username": "Benutzername",
"displayName": "Anzeigename",

View File

@@ -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"
}
}
}

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -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<Props> {
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<Props> {
{this.canManagePublicKeys() && (
<Route path={`${url}/settings/publicKeys`} render={() => <SetPublicKeys user={me} />} />
)}
{this.canManageApiKeys() && (
<Route path={`${url}/settings/apiKeys`} render={() => <SetApiKeys user={me} />} />
)}
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn>
<SecondaryNavigationColumn>
@@ -118,6 +128,7 @@ class Profile extends React.Component<Props> {
>
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
<SetPublicKeyNavLink user={me} publicKeyUrl={`${url}/settings/publicKeys`} />
<SetApiKeyNavLink user={me} apiKeyUrl={`${url}/settings/apiKeys`} />
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</SubNavigation>
)}

View File

@@ -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;

View File

@@ -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<Props> = ({
createLink,
refresh,
fetchAvailablePermissionsIfNeeded,
repositoryRolesLink,
repositoryVerbsLink,
availableRepositoryRoles
}) => {
const [t] = useTranslation("users");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<undefined | Error>();
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 <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
const availableRoleNames = availableRepositoryRoles ? availableRepositoryRoles.map(r => r.name) : [];
const closeModal = () => {
resetForm();
refresh();
setAddedKey("");
};
const newKeyModal = addedKey && <ApiKeyCreatedModal addedKey={addedKey} close={closeModal} />;
return (
<>
{newKeyModal}
<InputField label={t("apiKey.displayName")} value={displayName} onChange={setDisplayName} />
<RoleSelector
loading={!availableRoleNames}
availableRoles={availableRoleNames}
label={t("apiKey.permissionRole.label")}
helpText={t("apiKey.permissionRole.help")}
handleRoleChange={setPermissionRole}
role={permissionRole}
/>
<Level
right={<SubmitButton label={t("apiKey.addKey")} loading={loading} disabled={!isValid()} action={addKey} />}
/>
</>
);
};
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);

View File

@@ -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<Props> = ({ 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 = (
<div className={"media-content"}>
<p>{t("apiKey.modal.text1")}</p>
<p>
<b>{t("apiKey.modal.text2")}</b>
</p>
<hr />
<div className={"columns"}>
<div className={"column is-11"}>
<KeyArea wrap={"soft"} ref={keyRef} className={"input"} value={addedKey} />
</div>
<NoLeftMargin className={"column is-1"}>
<Icon className={"is-hidden-mobile fa-2x"} name={copied ? "clipboard-check" : "clipboard"} title={t("apiKey.modal.clipboard")} onClick={copy} />
</NoLeftMargin>
</div>
</div>
);
return (
<Modal
body={newPassphraseModalContent}
closeFunction={close}
title={t("apiKey.modal.title")}
footer={<Button label={t("apiKey.modal.close")} action={close} />}
active={true}
/>
);
};
export default ApiKeyCreatedModal;

View File

@@ -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<Props> = ({ apiKey, onDelete }) => {
const [t] = useTranslation("users");
let deleteButton;
if (apiKey?._links?.delete) {
deleteButton = (
<a className="level-item" onClick={() => onDelete((apiKey._links.delete as Link).href)}>
<span className="icon is-small">
<Icon name="trash" className="fas" title={t("apiKey.delete")} />
</span>
</a>
);
}
return (
<>
<tr>
<td>{apiKey.displayName}</td>
<td>{apiKey.permissionRole}</td>
<td className="is-hidden-mobile">
<DateFromNow date={apiKey.created}/>
</td>
<td className="is-darker">{deleteButton}</td>
</tr>
</>
);
};
export default ApiKeyEntry;

View File

@@ -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<Props> = ({ apiKeys, onDelete }) => {
const [t] = useTranslation("users");
if (apiKeys?._embedded?.keys?.length === 0) {
return <Notification type="info">{t("apiKey.noStoredKeys")}</Notification>;
}
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("apiKey.displayName")}</th>
<th>{t("apiKey.permissionRole.label")}</th>
<th>{t("apiKey.created")}</th>
<th />
</tr>
</thead>
<tbody>
{apiKeys?._embedded?.keys?.map((apiKey: ApiKey, index: number) => {
return <ApiKeyEntry key={index} onDelete={onDelete} apiKey={apiKey} />;
})}
</tbody>
</table>
);
};
export default ApiKeyTable;

View File

@@ -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<Props> = ({ user }) => {
const [t] = useTranslation("users");
const [error, setError] = useState<undefined | Error>();
const [loading, setLoading] = useState(false);
const [apiKeys, setApiKeys] = useState<ApiKeysCollection | undefined>(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 <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
return (
<>
<div className={"media-content"}>
<p>{t("apiKey.text1")} <Link to={"/admin/roles/"}>{t("apiKey.manageRoles")}</Link></p>
<p>{t("apiKey.text2")}</p>
</div>
<hr />
<ApiKeyTable apiKeys={apiKeys} onDelete={onDelete} />
<hr />
<Subtitle className={"media-content"}><h2 className={"title is-4"}>Create new key</h2></Subtitle>
{createLink && <AddApiKey createLink={createLink} refresh={fetchApiKeys} />}
</>
);
};
export default SetApiKeys;

View File

@@ -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<Props> = ({ user, apiKeyUrl }) => {
const [t] = useTranslation("users");
if ((user?._links?.apiKeys as Link)?.href) {
return <NavLink to={apiKeyUrl} label={t("singleUser.menu.setApiKeyNavLink")} />;
}
return null;
};
export default SetApiKeyNavLink;

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api;
import org.slf4j.Logger;
@@ -42,7 +42,7 @@ public class NotSupportedExceptionMapper implements ExceptionMapper<NotSupported
@Override
public Response toResponse(NotSupportedException exception) {
LOG.debug("illegal media type");
LOG.debug("illegal media type", exception);
ErrorDto error = new ErrorDto();
error.setTransactionId(MDC.get("transaction_id"));
error.setMessage("illegal media type");

View File

@@ -0,0 +1,57 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import sonia.scm.security.ApiKey;
import javax.inject.Inject;
import java.util.Collection;
import java.util.List;
import static de.otto.edison.hal.Link.link;
import static java.util.stream.Collectors.toList;
public class ApiKeyCollectionToDtoMapper {
private final ApiKeyToApiKeyDtoMapper apiKeyDtoMapper;
private final ResourceLinks resourceLinks;
@Inject
public ApiKeyCollectionToDtoMapper(ApiKeyToApiKeyDtoMapper apiKeyDtoMapper, ResourceLinks resourceLinks) {
this.apiKeyDtoMapper = apiKeyDtoMapper;
this.resourceLinks = resourceLinks;
}
public HalRepresentation map(Collection<ApiKey> keys) {
List<ApiKeyDto> dtos = keys.stream().map(apiKeyDtoMapper::map).collect(toList());
final Links.Builder links = Links.linkingTo()
.self(resourceLinks.apiKeyCollection().self())
.single(link("create", resourceLinks.apiKeyCollection().create()));
return new HalRepresentation(links.build(), Embedded.embedded("keys", dtos));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import 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());
}

View File

@@ -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));
}
}

View File

@@ -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);

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
@@ -35,6 +35,7 @@ import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
@@ -61,11 +62,14 @@ public class MeResource {
private final UserManager userManager;
private final PasswordService passwordService;
private final Provider<ApiKeyResource> apiKeyResource;
@Inject
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService) {
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResource) {
this.meDtoFactory = meDtoFactory;
this.userManager = userManager;
this.passwordService = passwordService;
this.apiKeyResource = apiKeyResource;
}
/**
@@ -118,4 +122,9 @@ public class MeResource {
);
return Response.noContent().build();
}
@Path("api_keys")
public ApiKeyResource apiKeys() {
return apiKeyResource.get();
}
}

View File

@@ -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());
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation;
@@ -131,7 +131,7 @@ public class UserResource {
*
* <strong>Note:</strong> This method requires "user" privilege.
*
* @param name name of the user to be modified
* @param name name of the user to be modified
* @param user user object to modify
*/
@PUT

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.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<? extends Realm> 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);
}

View File

@@ -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()
);
}
}

View File

@@ -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<ApiKeyWithPassphrase> keys;
public ApiKeyCollection add(ApiKeyWithPassphrase key) {
Collection<ApiKeyWithPassphrase> 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<ApiKeyWithPassphrase> predicate) {
Collection<ApiKeyWithPassphrase> newKeys = keys.stream().filter(key -> !predicate.test(key)).collect(toList());
return new ApiKeyCollection(newKeys);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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<ApiKeyCollection> store;
private final PasswordService passwordService;
private final KeyGenerator keyGenerator;
private final Supplier<String> passphraseGenerator;
private final ApiKeyTokenHandler tokenHandler;
private final Striped<ReadWriteLock> 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<String> 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<ApiKey> 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> T doSynchronized(String user, boolean write, Supplier<T> 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;
}
}

View File

@@ -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<byte[], String> encoder = Encoders.BASE64URL;
private static final Decoder<String, byte[]> 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<Token> 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);
}
}
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.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);
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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 <code>null</code>). Three examples:
* <table>
* <tr>
* <th>This permission</th>
* <th>Scope</th>
* <th>Resulting permission(s)</th>
* </tr>
* <tr>
* <td><code>repository:*:42</code></td>
* <td><code>repository:read,pull:*</code></td>
* <td><code>repository:read,pull:42</code></td>
* </tr>
* <tr>
* <td><code>repository:read:*</code></td>
* <td><code>repository:*:42</code>, <code>repository:*:1337</code></td>
* <td><code>repository:read:42</code>, <code>repository:read:1337</code></td>
* </tr>
* <tr>
* <td><code>user:*:*</code></td>
* <td><code>repository:read,pull:*</code></td>
* <td><i>empty</i></td>
* </tr>
* </table>
* @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 <code>null</code>).
*/
Collection<ScmWildcardPermission> limit(Scope scope) {
Collection<ScmWildcardPermission> 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<ScmWildcardPermission> 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<ScmWildcardPermission> 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<Set<String>> theseParts = getParts();
final List<Set<String>> 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<String> verbs = intersect(theseParts, scopeParts, 1);
Collection<String> 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<String> intersect(List<Set<String>> theseParts, List<Set<String>> scopeParts, int position) {
final Set<String> theseEntries = getEntries(theseParts, position);
final Set<String> scopeEntries = getEntries(scopeParts, position);
if (isWildcard(theseEntries)) {
return scopeEntries;
}
if (isWildcard(scopeEntries)) {
return theseEntries;
}
return CollectionUtils.intersection(theseEntries, scopeEntries);
}
/**
* Handles "shortened" permissions like <code>repository:read</code> that should be <code>repository:read:*</code>.
*/
private Set<String> getEntries(List<Set<String>> theseParts, int position) {
if (position >= theseParts.size()) {
return singleton(WILDCARD_TOKEN);
}
return theseParts.get(position);
}
private boolean isWildcard(Set<String> entries) {
return entries.size() == 1 && entries.contains(WILDCARD_TOKEN);
}
}

View File

@@ -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<Permission> authzPermissions = authzPermissions(resolver, authz);
Predicate<Permission> predicate = implies(authzPermissions);
Set<Permission> filteredPermissions = resolve(resolver, ImmutableList.copyOf(scope))
Set<Permission> filteredPermissions = authzPermissions
.stream()
.filter(predicate)
.map(p -> asScmWildcardPermission(p))
.map(p -> p.limit(scope))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
Set<String> 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 <T> Collection<T> nullToEmpty(Collection<T> collection) {
return collection != null ? collection : Collections.emptySet();
}
private static Collection<Permission> resolve(PermissionResolver resolver, Collection<String> permissions) {
private static Collection<ScmWildcardPermission> resolve(ScmPermissionResolver resolver, Collection<String> permissions) {
return Collections2.transform(nullToEmpty(permissions), resolver::resolvePermission);
}
private static Predicate<Permission> implies(Iterable<Permission> authzPermissions){
return (scopePermission) -> {
for ( Permission authzPermission : authzPermissions ) {
if (authzPermission.implies(scopePermission)) {
return true;
}
}
return false;
};
}
private static List<Permission> authzPermissions(PermissionResolver resolver, AuthorizationInfo authz){
private static List<Permission> authzPermissions(ScmPermissionResolver resolver, AuthorizationInfo authz){
List<Permission> authzPermissions = Lists.newArrayList();
authzPermissions.addAll(nullToEmpty(authz.getObjectPermissions()));
authzPermissions.addAll(resolve(resolver, authz.getStringPermissions()));
return authzPermissions;
}
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import 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<User> 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();

View File

@@ -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;
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.github.sdorra.shiro.ShiroRule;

View File

@@ -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;
}));
}
}

View File

@@ -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<String> passphraseGenerator = () -> Integer.toString(nextKey++);
KeyGenerator keyGenerator = () -> Integer.toString(nextId++);
ApiKeyTokenHandler tokenHandler = new ApiKeyTokenHandler();
DataStoreFactory storeFactory = new InMemoryDataStoreFactory(new InMemoryDataStore<ApiKeyCollection>());
DataStore<ApiKeyCollection> 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();
}
}
}

View File

@@ -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<ApiKeyTokenHandler.Token> 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<ApiKeyTokenHandler.Token> token = handler.readToken("invalid token");
assertThat(token).isEmpty();
}
@Test
void shouldNotFailWithInvalidTokenContent() {
Optional<ApiKeyTokenHandler.Token> token = handler.readToken(Encoders.BASE64URL.encode("{\"invalid\":\"token\"}".getBytes()));
assertThat(token).isEmpty();
}
}

View File

@@ -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) {

View File

@@ -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<ScmWildcardPermission> limitedPermissions = permission.limit("repository:write:*");
assertThat(limitedPermissions).isEmpty();
}
@Test
void shouldReturnScopeIfPermissionImpliesScope() {
ScmWildcardPermission permission = new ScmWildcardPermission("*");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read:42");
assertThat(limitedPermission).get().hasToString("repository:read:42");
}
@Test
void shouldReturnPermissionIfScopeImpliesPermission() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
Optional<ScmWildcardPermission> 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<ScmWildcardPermission> 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<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
}
@Test
void shouldHandleMissingEntriesAsWildcard() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read,write");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:*:42");
assertThat(limitedPermission).get().hasToString("repository:read,write:42");
}
@Test
void shouldEliminateEmptyVerbs() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:pull:42");
assertThat(limitedPermission).isEmpty();
}
@Test
void shouldEliminateEmptyId() {
ScmWildcardPermission permission = new ScmWildcardPermission("repository:read:42");
Optional<ScmWildcardPermission> limitedPermission = permission.limit("repository:read:23");
assertThat(limitedPermission).isEmpty();
}
}

View File

@@ -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<Permission> permissions = Sets.newLinkedHashSet();
for ( String value : values ) {
permissions.add(new WildcardPermission(value));
permissions.add(new ScmWildcardPermission(value));
}
info.setObjectPermissions(permissions);
return info;
}
}
}