Integrate Plugin Center myCloudogu Authentication (#1884)

Allows scm-manager instances to authenticate with the configured plugin center. If the default plugin center is used, a myCloudogu account is used for authentication which in turn enables downloading special myCloudogu plugins directly through the plugin administration page.

Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
Co-authored-by: Matthias Thieroff <93515444+mthieroff@users.noreply.github.com>
Co-authored-by: Philipp Ahrendt <philipp.ahrendt@cloudogu.com>
This commit is contained in:
Sebastian Sdorra
2021-12-13 15:15:57 +01:00
committed by GitHub
parent c95888d491
commit 6eba01161f
84 changed files with 3147 additions and 289 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View File

@@ -2,14 +2,32 @@
title: Administration title: Administration
subtitle: Plugins subtitle: Plugins
--- ---
Unter dem Eintrag "Plugins" können mithilfe des externen Plugin-Centers Plugins für den SCM Manager verwaltet werden. Die Plugins werden nach installierten und verfügbaren Plugins unterschieden und nach Funktionsschwerpunkt wie bspw. Workflow oder Authentifizierung gruppiert. Unter dem Eintrag "Plugins" werden mithilfe des externen Plugin-Centers Plugins für den SCM Manager verwaltet. Die Plugins werden nach installierten und verfügbaren Plugins unterschieden und nach Funktionsschwerpunkt wie bspw. Workflow oder Authentifizierung gruppiert.
Die Plugins können über Funktions-Icons auf den Kacheln verwaltet werden. Systemrelevante Plugins, die der SCM-Manager selbst liefert, können weder deinstalliert noch aktualisiert werden. Die Plugins können über Aktions-Icons auf den Kacheln verwaltet werden. Systemrelevante Plugins, die der SCM-Manager selbst liefert, können weder deinstalliert noch aktualisiert werden.
Damit Änderungen der Plugins wirksam werden, muss der SCM-Manager-Server neugestartet werden. Das kann nach jeder einzelnen Aktion erfolgen. Es ist aber auch möglich viele unterschiedliche Aktionen wie Installieren, Aktualisieren und Löschen in eine Warteschlange einzureihen und alle Aktionen mit einem einzigen Neustart auszuführen. Wird eine Aktion (Installieren, Deinstallieren, Aktualisieren) für ein Plugin ausgewählt, erscheinen die Schaltflächen "Änderungen ausführen" und "Änderungen abbrechen". Über "Änderungen ausführen" öffnet sich ein Pop-Up Fenster, indem die aktuelle Warteschlange (alle ausgeführten Aktionen ohne Neustart) angezeigt werden. Der Anwender hat nun die Möglichkeit zu entscheiden, ob die Änderungen durch einen Neustart ausgeführt werden sollen. Falls Aktionen, die sich bereits in der Warteschlange befinden nicht mehr erwünscht sind, kann die gesamte Warteschlange über den Button "Änderungen abbrechen" verworfen werden. Damit Änderungen der Plugins wirksam werden, muss der SCM-Manager-Server neu gestartet werden. Das kann nach jeder einzelnen Aktion erfolgen. Es ist aber auch möglich viele unterschiedliche Aktionen wie Installieren, Aktualisieren und Löschen in eine Warteschlange einzureihen und alle Aktionen mit einem einzigen Neustart auszuführen. Wird eine Aktion (Installieren, Deinstallieren, Aktualisieren) für ein Plugin ausgewählt, erscheinen die Schaltflächen Änderungen ausführen und Änderungen abbrechen. Über Änderungen ausführen öffnet sich ein Pop-Up Fenster, in dem die aktuelle Warteschlange (alle ausgeführten Aktionen ohne Neustart) angezeigt werden. Der Anwender hat nun die Möglichkeit zu entscheiden, ob die Änderungen durch einen Neustart ausgeführt werden sollen. Falls Aktionen, die sich bereits in der Warteschlange befinden nicht mehr erwünscht sind, kann die gesamte Warteschlange über den Button Änderungen abbrechen verworfen werden.
### myCloudogu-Plugins
Einige besondere Plugins sind nur für Instanzen des SCM-Managers verfügbar, die mit myCloudogu verbunden sind. Der SCM-Manager kann über den Button „Mit myCloudogu verbinden“ mit myCloudogu verbunden werden.
![Plugin-Center nicht verbunden, Button zur Verbindung mit myCloudogu](assets/administration-plugin-center-not-connected.png)
Sie werden dann zur myCloudogu-Login-Maske weitergeleitet.
![myCloudogu-Login-Maske](assets/myCloudogu-login.png)
Wenn Sie über ein myCloudogu-Konto verfügen, können Sie sich einloggen. Ansonsten erstellen Sie über einen konföderierten Identitätsanbieter (Google oder github) oder Ihre Email-Adresse ein Konto.
Anschließend werden Sie zurück zum SCM-Manager geleitet und können Details zur verbundenen Instanz und Konto überprüfen. Mit „Verbinden“ bestätigen Sie die Verbindung, mit „Abbrechen“ brechen Sie den Vorgang ab.
![Bestätigung der Verbindung mit myCloudogu](assets/administration-myC-confirmation.png)
Jetzt können Sie im Plugin-Center myCloudogu-Plugins genau wie Basis-Plugins installieren.
![SCM-Manager mit myCloudogu verbunden](assets/administration-plugin-center-connected.png)
Eine Instanz des SCM-Managers muss nur mit einem Konto verbunden werden, damit die myCloudogu-Plugins für die gesamte Instanz zur Verfügung stehen.
Sie können die Verbindung zu myCloudogu jederzeit unter Plugin Center Einstellungen in den Settings lösen.
#### Was ist myCloudogu und warum sollte ich ein Konto erstellen?
myCloudogu ist nicht nur die Heimat der SCM-Manager-Community. Sie können sich auch mit anderen Nutzenden austauschen, Bugs melden oder neue Funktionen im Forum zur Diskussion stellen.
myCloudogu bietet weiter besondere Plugins speziell für die Community an. In der Zukunft folgen weitere nützliche Plugins, die auch gemeinsam mit Partnern bereitgestellt werden.
Nutzen Sie erweiterte Plugin-Funktionen im SCM-Managers, treten Sie mit den Entwicklern in Kontakt und schließen Sie sich [myCloudogu](https://my.cloudogu.com) kostenfrei an!
### Installiert ### Installiert
Auf der Übersicht für installierte Plugins werden alle auf der SCM-Manager Instanz installierten Plugins angezeigt. Optionale Plugins können hier deinstalliert und aktualisiert werden. Auf der Übersicht für installierte Plugins werden alle auf der SCM-Manager Instanz installierten Plugins angezeigt. Optionale Plugins können hier aktualisiert und deinstalliert werden.
![Administration-Plugins-Installed](assets/administration-plugins-installed.png) ![Administration-Plugins-Installed](assets/administration-plugins-installed.png)

View File

@@ -23,8 +23,13 @@ Auf der Login-Seite des SCM-Managers werden hilfreiche Plugins und Features vorg
#### XSRF Protection aktivieren #### XSRF Protection aktivieren
Um Angriffe auf den SCM-Manager mit Cross Site Scripting (XSS / XSRF) zu erschweren. Dieses Feature ist noch experimentell. Um Angriffe auf den SCM-Manager mit Cross Site Scripting (XSS / XSRF) zu erschweren. Dieses Feature ist noch experimentell.
#### Plugin-Center-URL #### Plugin-Settings
Der SCM-Manager kann ein Plugin-Center anbinden, um schnell und bequem Plugins verwalten zu können. Um ein anderes SCM-Plugin-Center als das vorkonfigurierte zu verwenden, reicht es aus diese URL zu ändern. Läuft der SCM-Manager im Cloudogu EcoSystem kann die Plugin Center URL über einen Eintrag im etcd gesetzt werden. Der SCM-Manager kann ein Plugin-Center anbinden, um schnell und bequem Plugins verwalten zu können. Um ein anderes SCM-Plugin-Center als das vorkonfigurierte zu verwenden, reicht es aus diese URL zu ändern. Läuft der SCM-Manager im Cloudogu EcoSystem kann die Plugin Center URL über einen Eintrag im etcd gesetzt werden.
Wenn das vorkonfigurierte Plugin-Center verwendet wird, kann der SCM-Manager mit myCloudogu verbunden werden.
![Einstellungen, Plugin-Center nicht mit myCloudogu verbunden](assets/administration-settings-not-connected.png)
So können über das Plugin-Center besondere myCloudogu-Plugins bezogen werden. Details sind in der Dokumentation des Plugin-Centers aufgeführt.
Eine bestehende Verbindung zwischen dem SCM-Manager und myCloudogu kann hier aufgehoben werden.
![Einstellungen, Plugin-Center mit myCloudogu verbunden, Button zum Lösen der Verbindung](assets/administration-settings-connected.png)
#### Anonyme Zugriff #### Anonyme Zugriff
Der SCM-Manager 2 hat das Konzept für anonyme Zugriffe über einen "_anonymous"-Benutzer realisiert. Beim Aktivieren des anonymen Zugriffs wird ein neuer Benutzer erstellt mit dem Namen "_anonymous". Dieser Nutzer kann wie ein gewöhnlicher Benutzer für unterschiedliche Aktionen berechtigt werden. Bei einem Zugriff auf den SCM-Manager ohne Zugangsdaten wird dieser anonyme Benutzer verwendet. Der SCM-Manager 2 hat das Konzept für anonyme Zugriffe über einen "_anonymous"-Benutzer realisiert. Beim Aktivieren des anonymen Zugriffs wird ein neuer Benutzer erstellt mit dem Namen "_anonymous". Dieser Nutzer kann wie ein gewöhnlicher Benutzer für unterschiedliche Aktionen berechtigt werden. Bei einem Zugriff auf den SCM-Manager ohne Zugangsdaten wird dieser anonyme Benutzer verwendet.

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -2,18 +2,36 @@
title: Administration title: Administration
subtitle: Plugins subtitle: Plugins
--- ---
In the plugins section, plugins for SCM-Manager can be managed with the help of the external plugin center. Plugins are distinguished between installed and available plugins and are grouped based on their main functionality like for example workflow or authentication. In the plugins section, plugins for SCM-Manager can be managed with the help of the external plugin center. Plugins are distinguished between installed and available plugins. They are grouped based on their main functionality like for example workflow or authentication.
Plugins can be managed by functionality icons on the tiles. System relevant plugins that come with SCM-Manager by default cannot be deinstalled or updated. Plugins can be managed by action icons on the tiles. System relevant plugins that come with SCM-Manager by default cannot be uninstalled or updated.
In order for changes to plugins to become effective, the SCM-Manager server needs to be restarted. That can be done after every single action. It is also possible to queue several actions like the installation of a new plugin, updates or the deletion of a plugin and to perform all actions with one restart. If an action (installation, deinstallation, update) for a plugin was performed, the buttons "Execute changes" and "Abort changes" appear. If you choose to execute the changes, a popup window that shows the current queue (all actions without a restart) appears. Now the user can decide whether to execute the changes by restarting the server. If there are actions in the queue that are no longer desired, the queue can be emptied with the about changes button. In order for changes to plugins to become effective, the SCM-Manager server needs to be restarted. That can be done after every single action. It is also possible to queue several actions like the installation of a new plugin, updates or the deletion of a plugin and to perform all actions with one restart. If an action (installation, uninstallation, update) for a plugin was performed, the buttons "Execute changes" and "Abort changes" appear. If you choose to execute the changes, a popup window that shows the current queue (all actions without a restart) appears. Now the user can decide whether to execute the changes by restarting the server. If there are actions in the queue that are no longer desired, the queue can be emptied with the abort changes button.
### myCloudogu plugins
Some special plugins are only available to instances of SCM-Manager that are connected to myCloudogu. You may connect your instance by clicking the button “Connect to myCloudogu” ![Plugin-center not connected](assets/administration-plugin-center-not-connected.png).
You will be redirected to a myCloudogu login form. ![myCloudogu-Login-Form](assets/myCloudogu-Login.png) If you already have an account you simply log in. Otherwise you can create an account either by using a confederate identity provider (Google or github)
or with your email.
After a successful login you will return to the SCM-Manager. Here you can review the instance and account to connect. By clicking the button “Connect” you approve the connection and return to the plugin center. ![Confirmation of connection](assets/administration-myC-confirmation.png)
Now you can install myCloudogu plugins like basic plugins.
![Plugin-center connected with myCloudogu](assets/administration-plugin-center-connected.png)
Only one user with sufficient permissions needs to connect the instance with myCloudogu. The myCloudogu plugins can than be installed by every user with suitable permissions.
You can always sever the connection in the plugin center settings in global settings of your instance.
#### What is myCloudogu and why should you create an account?
myCloudogu is not only the home of the SCM-Manager community. You can connect to other users, get help and express feature requests in the forum.
myCloudogu also serves special plugins to provide more value for our community. In the future myCloudogu will offer exiting plugins developed in cooperation with our partners.
To unlock the full power of SCM-Manager and to hang out with our developers, join [myCloudogu](https://my.cloudogu.com/) for free!
### Installed ### Installed
The overview for installed plugins shows all plugins that are currently installed on the SCM-Manager instance. Plugins that are optional can be deinstalled or updated here. The overview for installed plugins shows all plugins that are currently installed on the SCM-Manager instance. Optional plugins can be uninstalled or updated here.
![Administration-Plugins-Installed](assets/administration-plugins-installed.png) ![Administration-Plugins-Installed](assets/administration-plugins-installed.png)
### Available ### Available
The overview of all available plugins shows all plugins that are compatible with the current version of the SCM-Manager instance that are available through the SCM-plugin-center. The plugins can be downloaded by clicking on the icon and will be installed after a restart of the SCM-Manager server. The overview of all available plugins shows all plugins that are compatible with the current version of the SCM-Manager instance that are available through the SCM-plugin-center. The plugins can be downloaded by clicking on the icon and will be installed after a restart of the SCM-Manager server.
Special myCloudogu-plugins can be installed the same way if your instance of SCM-Manager is connected to myCloudogu as described above.
![Administration-Plugins-Available](assets/administration-plugins-available.png) ![Administration-Plugins-Available](assets/administration-plugins-available.png)

View File

@@ -23,8 +23,12 @@ The login screen of SCM-Manager shows helpful plugins and features. If you want
#### Enable XSRF Protection #### Enable XSRF Protection
Activate this option to make attacks using cross site scripting (XSS / XSRF) on SCM-Manager more difficult. This feature is still in an experimental state. Activate this option to make attacks using cross site scripting (XSS / XSRF) on SCM-Manager more difficult. This feature is still in an experimental state.
#### Plugin Center URL #### Plugin-Settings
A plugin center can be used to conveniently manage plugins. If you want to use a plugin center that is not the default one, you only have to change this URL. If SCM-Manager is operated as part of a Cloudogu EcoSystem, the plugin center URL can be changed in the etcd. A plugin center can be used to conveniently manage plugins. If you want to use a plugin center that is not the default one, you only have to change this URL. If SCM-Manager is operated as part of a Cloudogu EcoSystem, the plugin center URL can be changed in the etcd.
If the default plugin center is used, the SCM-Manager may be connected to myCloudogu to receive special myCloudogu-Plugins. Details can be found in the plugin-center documentation.
![Plugin center settings, not connected to myCloudogu](assets/administration-setings-not-connected.png)
An existing connection between a SCM-Manager and myCloudogu may be severed here.
![Plugin center settings, button sever connection to myCloudogu](assets/administration-settings-connected.png)
#### Anonymous Access #### Anonymous Access
In SCM-Manager 2 the access for anonymous access is realized by using an "_anonymous" user. When the feature is activated, a new user with the name "_anonymous" is created. This user can be authorized just like any other user. This user is used for access to SCM-Manager without login credentials. In SCM-Manager 2 the access for anonymous access is realized by using an "_anonymous" user. When the feature is activated, a new user with the name "_anonymous" is created. This user can be authorized just like any other user. This user is used for access to SCM-Manager without login credentials.

View File

@@ -69,6 +69,13 @@ public class ScmConfiguration implements Configuration {
public static final String DEFAULT_PLUGINURL = public static final String DEFAULT_PLUGINURL =
"https://plugin-center-api.scm-manager.org/api/v1/plugins/{version}?os={os}&arch={arch}&jre={jre}"; "https://plugin-center-api.scm-manager.org/api/v1/plugins/{version}?os={os}&arch={arch}&jre={jre}";
/**
* Default url for plugin center authentication.
* @since 2.28.0
*/
public static final String DEFAULT_PLUGIN_AUTH_URL =
"https://plugin-center-api.scm-manager.org/api/v1/auth/oidc";
/** /**
* SCM Manager release feed url * SCM Manager release feed url
*/ */
@@ -154,6 +161,9 @@ public class ScmConfiguration implements Configuration {
@XmlElement(name = "plugin-url") @XmlElement(name = "plugin-url")
private String pluginUrl = DEFAULT_PLUGINURL; private String pluginUrl = DEFAULT_PLUGINURL;
@XmlElement(name = "plugin-auth-url")
private String pluginAuthUrl = DEFAULT_PLUGIN_AUTH_URL;
@XmlElement(name = "release-feed-url") @XmlElement(name = "release-feed-url")
private String releaseFeedUrl = DEFAULT_RELEASE_FEED_URL; private String releaseFeedUrl = DEFAULT_RELEASE_FEED_URL;
@@ -163,7 +173,7 @@ public class ScmConfiguration implements Configuration {
* @since 1.34 * @since 1.34
*/ */
@XmlElement(name = "login-attempt-limit-timeout") @XmlElement(name = "login-attempt-limit-timeout")
private long loginAttemptLimitTimeout = TimeUnit.MINUTES.toSeconds(5l); private long loginAttemptLimitTimeout = TimeUnit.MINUTES.toSeconds(5L);
private boolean enableProxy = false; private boolean enableProxy = false;
@@ -243,6 +253,7 @@ public class ScmConfiguration implements Configuration {
this.realmDescription = other.realmDescription; this.realmDescription = other.realmDescription;
this.dateFormat = other.dateFormat; this.dateFormat = other.dateFormat;
this.pluginUrl = other.pluginUrl; this.pluginUrl = other.pluginUrl;
this.pluginAuthUrl = other.pluginAuthUrl;
this.anonymousMode = other.anonymousMode; this.anonymousMode = other.anonymousMode;
this.enableProxy = other.enableProxy; this.enableProxy = other.enableProxy;
this.proxyPort = other.proxyPort; this.proxyPort = other.proxyPort;
@@ -319,6 +330,24 @@ public class ScmConfiguration implements Configuration {
return pluginUrl; return pluginUrl;
} }
/**
* Returns the url which is used for plugin center authentication.
* @return authentication url
* @since 2.28.0
*/
public String getPluginAuthUrl() {
return pluginAuthUrl;
}
/**
* Returns {@code true} if the default plugin auth url is used.
* @return {@code true} if the default plugin auth url is used
* @since 2.28.0
*/
public boolean isDefaultPluginAuthUrl() {
return DEFAULT_PLUGIN_AUTH_URL.equals(pluginAuthUrl);
}
/** /**
* Returns the url of the rss release feed. * Returns the url of the rss release feed.
* *
@@ -543,6 +572,15 @@ public class ScmConfiguration implements Configuration {
this.pluginUrl = pluginUrl; this.pluginUrl = pluginUrl;
} }
/**
* Set the url for plugin center authentication.
* @param pluginAuthUrl authentication url
* @since 2.28.0
*/
public void setPluginAuthUrl(String pluginAuthUrl) {
this.pluginAuthUrl = pluginAuthUrl;
}
public void setReleaseFeedUrl(String releaseFeedUrl) { public void setReleaseFeedUrl(String releaseFeedUrl) {
this.releaseFeedUrl = releaseFeedUrl; this.releaseFeedUrl = releaseFeedUrl;
} }

View File

@@ -106,6 +106,18 @@ public abstract class BaseHttpRequest<T extends BaseHttpRequest>
return self(); return self();
} }
/**
* Enable authentication with a bearer token.
* @param bearerToken bearer token
* @return http request instance
* @since 2.28.0
*/
public T bearerAuth(String bearerToken) {
headers.put("Authorization", "Bearer ".concat(bearerToken));
return self();
}
/** /**
* Enable or disabled gzip decoding. The default value is false. * Enable or disabled gzip decoding. The default value is false.
* *

View File

@@ -69,6 +69,7 @@ public class VndMediaType {
public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX; public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX;
public static final String PLUGIN = PREFIX + "plugin" + SUFFIX; public static final String PLUGIN = PREFIX + "plugin" + SUFFIX;
public static final String PLUGIN_COLLECTION = PREFIX + "pluginCollection" + SUFFIX; public static final String PLUGIN_COLLECTION = PREFIX + "pluginCollection" + SUFFIX;
public static final String PLUGIN_CENTER_AUTH_INFO = PREFIX + "pluginCenterAuthInfo" + SUFFIX;
public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX; public static final String UI_PLUGIN = PREFIX + "uiPlugin" + SUFFIX;
public static final String UI_PLUGIN_COLLECTION = PREFIX + "uiPluginCollection" + SUFFIX; public static final String UI_PLUGIN_COLLECTION = PREFIX + "uiPluginCollection" + SUFFIX;
@SuppressWarnings("squid:S2068") @SuppressWarnings("squid:S2068")

View File

@@ -0,0 +1,46 @@
/*
* 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.config;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ScmConfigurationTest {
private final ScmConfiguration scmConfiguration = new ScmConfiguration();
@Test
void shouldReturnTrueForInitialPluginAuthUrl() {
assertThat(scmConfiguration.isDefaultPluginAuthUrl()).isTrue();
}
@Test
void shouldReturnFalseIfPluginAuthUrlHasChanged() {
scmConfiguration.setPluginAuthUrl("https://plug.ins/oidc");
assertThat(scmConfiguration.isDefaultPluginAuthUrl()).isFalse();
}
}

View File

@@ -28,108 +28,116 @@ import com.google.common.collect.Lists;
import com.google.common.collect.Multimap; import com.google.common.collect.Multimap;
import java.io.IOException; import java.io.IOException;
import java.util.Collection; import java.util.Collection;
import org.junit.Test;
import static org.junit.Assert.*; import org.junit.jupiter.api.BeforeEach;
import static org.hamcrest.Matchers.*; import org.junit.jupiter.api.Test;
import org.junit.Before; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.runner.RunWith;
import org.mockito.Mock; import org.mockito.Mock;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.jupiter.MockitoExtension;
/** /**
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
@RunWith(MockitoJUnitRunner.class) @ExtendWith(MockitoExtension.class)
public class BaseHttpRequestTest { class BaseHttpRequestTest {
@Mock @Mock
private AdvancedHttpClient ahc; private AdvancedHttpClient ahc;
private BaseHttpRequest<AdvancedHttpRequest> request; private BaseHttpRequest<AdvancedHttpRequest> request;
@Before @BeforeEach
public void before(){ public void before(){
request = new AdvancedHttpRequest(ahc, HttpMethod.GET, "https://www.scm-manager.org"); request = new AdvancedHttpRequest(ahc, HttpMethod.GET, "https://www.scm-manager.org");
} }
@Test @Test
public void testBasicAuth() void shouldAddAuthorizationHeaderWithBasicScheme() {
{
request.basicAuth("tricia", "mcmillian123"); request.basicAuth("tricia", "mcmillian123");
Multimap<String,String> headers = request.getHeaders(); Multimap<String,String> headers = request.getHeaders();
assertEquals("Basic dHJpY2lhOm1jbWlsbGlhbjEyMw==", headers.get("Authorization").iterator().next()); assertThat(headers.get("Authorization").iterator().next()).isEqualTo("Basic dHJpY2lhOm1jbWlsbGlhbjEyMw==");
}
@Test
void shouldAddAuthorizationHeaderWithBearerScheme() {
request.bearerAuth("awesome-access-token");
Multimap<String,String> headers = request.getHeaders();
assertThat(headers.get("Authorization").iterator().next()).isEqualTo("Bearer awesome-access-token");
} }
@Test @Test
public void testQueryString(){ void shouldAppendQueryString(){
request.queryString("a", "b"); request.queryString("a", "b");
assertEquals("https://www.scm-manager.org?a=b", request.getUrl()); assertThat(request.getUrl()).isEqualTo("https://www.scm-manager.org?a=b");
} }
@Test @Test
public void testQueryStringMultiple(){ void shouldAppendMultipleQueryStrings(){
request.queryString("a", "b"); request.queryString("a", "b");
request.queryString("c", "d", "e"); request.queryString("c", "d", "e");
assertEquals("https://www.scm-manager.org?a=b&c=d&c=e", request.getUrl()); assertThat(request.getUrl()).isEqualTo("https://www.scm-manager.org?a=b&c=d&c=e");
} }
@Test @Test
public void testQueryStringEncoded(){ void shouldEscapeQueryString(){
request.queryString("a", "äüö"); request.queryString("a", "äüö");
assertEquals("https://www.scm-manager.org?a=%C3%A4%C3%BC%C3%B6", request.getUrl()); assertThat(request.getUrl()).isEqualTo("https://www.scm-manager.org?a=%C3%A4%C3%BC%C3%B6");
} }
@Test @Test
public void testQueryStrings(){ void shouldAppendQueryStringFromIterable(){
Iterable<? extends Object> i1 = Lists.newArrayList("b"); Iterable<?> i1 = Lists.newArrayList("b");
Iterable<? extends Object> i2 = Lists.newArrayList("d", "e"); Iterable<?> i2 = Lists.newArrayList("d", "e");
request.queryStrings("a", i1); request.queryStrings("a", i1);
request.queryStrings("c", i2); request.queryStrings("c", i2);
assertEquals("https://www.scm-manager.org?a=b&c=d&c=e", request.getUrl());
assertThat(request.getUrl()).isEqualTo("https://www.scm-manager.org?a=b&c=d&c=e");
} }
@Test @Test
public void testQuerqStringNullValue(){ void ShouldNotAppendQueryStringWithNullValue(){
request.queryString("a", null, "b"); request.queryString("a", null, "b");
assertEquals("https://www.scm-manager.org?a=&a=b", request.getUrl()); assertThat(request.getUrl()).isEqualTo("https://www.scm-manager.org?a=&a=b");
} }
@Test @Test
public void testHeader(){ void shouldAddHeader(){
request.header("a", "b"); request.header("a", "b");
assertEquals("b", request.getHeaders().get("a").iterator().next()); assertThat(request.getHeaders().get("a").iterator().next()).isEqualTo("b");
} }
@Test @Test
public void testHeaderMultiple(){ void shouldAddHeaderWithMultipleValues(){
request.header("a", "b", "c", "d"); request.header("a", "b", "c", "d");
Collection<String> values = request.getHeaders().get("a"); assertThat( request.getHeaders().get("a")).contains("b", "c", "d");
assertThat(values, contains("b", "c", "d"));
} }
@Test @Test
public void testRequest() throws IOException{ void shouldExecuteWithClient() throws IOException{
request.request(); request.request();
verify(ahc).request(request); verify(ahc).request(request);
} }
@Test @Test
public void testBuilderMethods(){ void shouldApplyValueFromBuilderMethods(){
Iterable<? extends Object> i1 = Lists.newArrayList("b"); Iterable<?> i1 = Lists.newArrayList("b");
assertThat(request.decodeGZip(true), instanceOf(AdvancedHttpRequest.class)); assertThat(request.decodeGZip(true)).isInstanceOf(AdvancedHttpRequest.class);
assertTrue(request.isDecodeGZip()); assertThat(request.isDecodeGZip()).isTrue();
assertThat(request.disableCertificateValidation(true), instanceOf(AdvancedHttpRequest.class)); assertThat(request.disableCertificateValidation(true)).isInstanceOf(AdvancedHttpRequest.class);
assertTrue(request.isDisableCertificateValidation()); assertThat(request.isDisableCertificateValidation()).isTrue();
assertThat(request.disableHostnameValidation(true), instanceOf(AdvancedHttpRequest.class)); assertThat(request.disableHostnameValidation(true)).isInstanceOf(AdvancedHttpRequest.class);
assertTrue(request.isDisableHostnameValidation()); assertThat(request.isDisableHostnameValidation()).isTrue();
assertThat(request.ignoreProxySettings(true), instanceOf(AdvancedHttpRequest.class)); assertThat(request.ignoreProxySettings(true)).isInstanceOf(AdvancedHttpRequest.class);
assertTrue(request.isIgnoreProxySettings()); assertThat(request.isIgnoreProxySettings()).isTrue();
assertThat(request.header("a", "b"), instanceOf(AdvancedHttpRequest.class)); assertThat(request.header("a", "b")).isInstanceOf(AdvancedHttpRequest.class);
assertThat(request.headers("a", i1), instanceOf(AdvancedHttpRequest.class)); assertThat(request.headers("a", i1)).isInstanceOf(AdvancedHttpRequest.class);
assertThat(request.queryString("a", "b"), instanceOf(AdvancedHttpRequest.class)); assertThat(request.queryString("a", "b")).isInstanceOf(AdvancedHttpRequest.class);
assertThat(request.queryStrings("a", i1), instanceOf(AdvancedHttpRequest.class)); assertThat(request.queryStrings("a", i1)).isInstanceOf(AdvancedHttpRequest.class);
} }
} }

View File

@@ -45,4 +45,8 @@ public class InMemoryConfigurationStore<T> implements ConfigurationStore<T> {
this.object = obejct; this.object = obejct;
} }
@Override
public void delete() {
object = null;
}
} }

View File

@@ -50,6 +50,7 @@ describe("Test config hooks", () => {
namespaceStrategy: "", namespaceStrategy: "",
emergencyContacts: [], emergencyContacts: [],
pluginUrl: "", pluginUrl: "",
pluginAuthUrl: "",
proxyExcludes: [], proxyExcludes: [],
proxyPassword: null, proxyPassword: null,
proxyPort: 0, proxyPort: 0,

View File

@@ -59,6 +59,7 @@ export * from "./contentType";
export * from "./annotations"; export * from "./annotations";
export * from "./search"; export * from "./search";
export * from "./loginInfo"; export * from "./loginInfo";
export * from "./usePluginCenterAuthInfo";
export { default as ApiProvider } from "./ApiProvider"; export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider"; export * from "./ApiProvider";

View File

@@ -0,0 +1,78 @@
/*
* 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 { ApiResult, useIndexLink } from "./base";
import { Link, PluginCenterAuthenticationInfo } from "@scm-manager/ui-types";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { useLocation } from "react-router-dom";
export const usePluginCenterAuthInfo = (): ApiResult<PluginCenterAuthenticationInfo> => {
const link = useIndexLink("pluginCenterAuth");
const location = useLocation();
return useQuery<PluginCenterAuthenticationInfo, Error>(
["pluginCenterAuth"],
() => {
if (!link) {
throw new Error("no such plugin center auth link");
}
return apiClient
.get(link)
.then(response => response.json())
.then((result: PluginCenterAuthenticationInfo) => {
if (result._links?.login) {
(result._links.login as Link).href += `?source=${location.pathname}`;
}
return result;
});
},
{
enabled: !!link
}
);
};
export const usePluginCenterLogout = (authenticationInfo: PluginCenterAuthenticationInfo) => {
const queryClient = useQueryClient();
const { mutate, isLoading, error } = useMutation<unknown, Error>(
() => {
if (!authenticationInfo._links.logout) {
throw new Error("authenticationInfo has no logout link");
}
const logout = authenticationInfo._links.logout as Link;
return apiClient.delete(logout.href);
},
{
onSuccess: () => queryClient.invalidateQueries("pluginCenterAuth")
}
);
return {
logout: () => {
mutate();
},
isLoading,
error
};
};

View File

@@ -61,32 +61,43 @@ const Button: FC<Props> = ({
loading, loading,
disabled, disabled,
action, action,
color = "default", color = "default"
}) => { }) => {
const renderIcon = () => { const renderIcon = () => {
return <>{icon ? <Icon name={icon} color="inherit" className="is-medium pr-1" /> : null}</>; return <>{icon ? <Icon name={icon} color="inherit" className="is-medium pr-1" /> : null}</>;
}; };
const classes = classNames(
"button",
"is-" + color,
{ "is-loading": loading },
{ "is-fullwidth": fullWidth },
{ "is-reduced-mobile": reducedMobile },
className
);
const content = (
<>
{renderIcon()}{" "}
{(label || children) && (
<>
{label} {children}
</>
)}
</>
);
if (link && !disabled) { if (link && !disabled) {
if (link.includes("://")) {
return (
<a className={classes} href={link} aria-label={label}>
{content}
</a>
);
}
return ( return (
<Link <Link className={classes} to={link} aria-label={label}>
className={classNames( {content}
"button",
"is-" + color,
{ "is-loading": loading },
{ "is-fullwidth": fullWidth },
{ "is-reduced-mobile": reducedMobile },
className
)}
to={link}
aria-label={label}
>
{renderIcon()}{" "}
{(label || children) && (
<>
{label} {children}
</>
)}
</Link> </Link>
); );
} }
@@ -96,23 +107,11 @@ const Button: FC<Props> = ({
type={type} type={type}
title={title} title={title}
disabled={disabled} disabled={disabled}
onClick={(event) => action && action(event)} onClick={event => action && action(event)}
className={classNames( className={classes}
"button",
"is-" + color,
{ "is-loading": loading },
{ "is-fullwidth": fullWidth },
{ "is-reduced-mobile": reducedMobile },
className
)}
{...createAttributesForTesting(testId)} {...createAttributesForTesting(testId)}
> >
{renderIcon()}{" "} {content}
{(label || children) && (
<>
{label} {children}
</>
)}
</button> </button>
); );
}; };

View File

@@ -384,6 +384,15 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
.has-border-white { .has-border-white {
border-color: $white !important; border-color: $white !important;
} }
.has-border-success {
border-color: $success !important;
}
.has-border-info {
border-color: $info !important;
}
ul.is-separated { ul.is-separated {
> li:after { > li:after {
content: ",\2800"; content: ",\2800";
@@ -396,12 +405,12 @@ ul.is-separated {
// card columns for repo and plugins overview // card columns for repo and plugins overview
.card-columns { .card-columns {
.column { .column {
height: 120px; height: 160px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
.overlay-column { .overlay-column {
position: absolute; position: absolute;
height: calc(120px - 1.5rem); height: calc(160px - 1.5rem);
} }
} }

View File

@@ -43,6 +43,7 @@ export type Config = HalRepresentation & {
proxyExcludes: string[]; proxyExcludes: string[];
skipFailedAuthenticators: boolean; skipFailedAuthenticators: boolean;
pluginUrl: string; pluginUrl: string;
pluginAuthUrl: string;
loginAttemptLimitTimeout: number; loginAttemptLimitTimeout: number;
enabledXsrfProtection: boolean; enabledXsrfProtection: boolean;
enabledUserConverter: boolean; enabledUserConverter: boolean;

View File

@@ -59,3 +59,10 @@ export type PendingPlugins = HalRepresentationWithEmbedded<{
update: Plugin[]; update: Plugin[];
uninstall: Plugin[]; uninstall: Plugin[];
}>; }>;
export type PluginCenterAuthenticationInfo = HalRepresentation & {
principal?: string;
pluginCenterSubject?: string;
date?: string;
default: boolean;
};

View File

@@ -49,7 +49,7 @@
"install": "{{name}} Plugin installieren", "install": "{{name}} Plugin installieren",
"update": "{{name}} Plugin aktualisieren", "update": "{{name}} Plugin aktualisieren",
"uninstall": "{{name}} Plugin deinstallieren", "uninstall": "{{name}} Plugin deinstallieren",
"cloudoguInstall": "Plugin über myCloudogu installieren" "cloudoguInstall": "{{name}} Plugin installieren"
}, },
"restart": "Neustarten, um Plugin-Änderungen wirksam zu machen", "restart": "Neustarten, um Plugin-Änderungen wirksam zu machen",
"install": "Installieren", "install": "Installieren",
@@ -69,8 +69,8 @@
"version": "Version", "version": "Version",
"currentVersion": "Installierte Version", "currentVersion": "Installierte Version",
"newVersion": "Neue Version", "newVersion": "Neue Version",
"cloudoguInstallInfo": "Dieses Plugin ist exklusiv über myCloudogu erhältlich. Zum Installieren folgen Sie bitte der Anleitung.", "cloudoguInstallInfo": "Verbinden Sie Ihre SCM-Manager-Instanz mit ihrem myCloudogu-Account um Zugriff auf myCloudogu-Plugins zu erhalten. Falls Sie noch über keinen Account verfügen, können Sie sich einfach kostenlos registrieren.",
"cloudoguInstall": "Zur Installationsanleitung", "cloudoguInstall": "Mit myCloudogu verbinden und installieren",
"dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installiert bzw. aktualisiert, wenn sie noch nicht in der aktuellen Version vorhanden sind!", "dependencyNotification": "Mit diesem Plugin werden folgende Abhängigkeiten mit installiert bzw. aktualisiert, wenn sie noch nicht in der aktuellen Version vorhanden sind!",
"optionalDependencyNotification": "Mit diesem Plugin werden folgende optionale Abhängigkeiten mit aktualisiert, falls sie installiert sind!", "optionalDependencyNotification": "Mit diesem Plugin werden folgende optionale Abhängigkeiten mit aktualisiert, falls sie installiert sind!",
"dependencies": "Abhängigkeiten", "dependencies": "Abhängigkeiten",
@@ -85,6 +85,15 @@
"updateAllInfo": "Die folgenden Plugins werden aktualisiert. Die Änderungen werden nach dem nächsten Neustart wirksam.", "updateAllInfo": "Die folgenden Plugins werden aktualisiert. Die Änderungen werden nach dem nächsten Neustart wirksam.",
"manualRestartRequired": "Nachdem die Plugin-Änderung durchgeführt wurde, muss SCM-Manager neu gestartet werden.", "manualRestartRequired": "Nachdem die Plugin-Änderung durchgeführt wurde, muss SCM-Manager neu gestartet werden.",
"showPending": "Um die folgenden Plugin-Änderungen auszuführen, muss SCM-Manager neu gestartet werden." "showPending": "Um die folgenden Plugin-Änderungen auszuführen, muss SCM-Manager neu gestartet werden."
},
"myCloudogu": {
"connectionInfo": "Instanz ist mit myCloudogu verbunden.\nAccount: {{pluginCenterSubject}}",
"login": {
"button": {
"label": "Mit <0>myCloudogu</0> verbinden"
},
"description": "Verbinden Sie Ihren SCM-Manager mit <0>myCloudogu</0> um besondere Plugins zu installieren. myCloudogu ist die Heimat der SCM-Manager Community, getragen von Maintainern des SCM-Managers. Sie haben noch kein Konto? Erstellen Sie während der Verbindung der SCM-Manager-Instanz kostenfrei ein myCloudogu-Konto."
}
} }
}, },
"repositoryRole": { "repositoryRole": {

View File

@@ -11,6 +11,19 @@
"no-write-permission-notification": "Hinweis: Es fehlen Berechtigungen zum Bearbeiten der Einstellungen!" "no-write-permission-notification": "Hinweis: Es fehlen Berechtigungen zum Bearbeiten der Einstellungen!"
} }
}, },
"pluginSettings": {
"subtitle": "Plugin Einstellungen",
"pluginUrl": "Plugin Center URL",
"pluginAuthUrl": "Plugin Center Authentifizierungs URL",
"auth": {
"loading": "Lade Authentifizierungs Informationen ...",
"notAuthenticated": "Das Plugin Center ist nicht authentifiziert",
"authenticate": "Authentifizieren",
"authenticated": "Das Plugin Center ist als <0 /> authentifiziert",
"subjectTooltip": "Authentifiziert als {{ principal }} {{ ago }}",
"logout": "Abmelden"
}
},
"proxySettings": { "proxySettings": {
"subtitle": "Proxy Einstellungen", "subtitle": "Proxy Einstellungen",
"enable": "Proxy aktivieren", "enable": "Proxy aktivieren",
@@ -54,7 +67,6 @@
"off": "Deaktivieren" "off": "Deaktivieren"
}, },
"skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen", "skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen",
"plugin-url": "Plugin Center URL",
"release-feed-url": "Release Feed URL", "release-feed-url": "Release Feed URL",
"mail-domain-name": "Fallback E-Mail Domain Name", "mail-domain-name": "Fallback E-Mail Domain Name",
"enabled-xsrf-protection": "XSRF Protection aktivieren", "enabled-xsrf-protection": "XSRF Protection aktivieren",
@@ -79,6 +91,7 @@
"realmDescriptionHelpText": "Beschreibung des Authentication Realm.", "realmDescriptionHelpText": "Beschreibung des Authentication Realm.",
"dateFormatHelpText": "Moments Datumsformat. Zulässige Formate sind in der MomentJS Dokumentation beschrieben.", "dateFormatHelpText": "Moments Datumsformat. Zulässige Formate sind in der MomentJS Dokumentation beschrieben.",
"pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", "pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur",
"pluginAuthUrlHelpText": "Die URL der Plugin Center Authentifizierungs API.",
"releaseFeedUrlHelpText": "Die URL des RSS Release Feed des SCM-Manager. Darüber wird über die neue SCM-Manager Version informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.", "releaseFeedUrlHelpText": "Die URL des RSS Release Feed des SCM-Manager. Darüber wird über die neue SCM-Manager Version informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.",
"mailDomainNameHelpText": "Dieser Domain Name wird genutzt, wenn für einen User eine E-Mail-Adresse benötigt wird, für den keine hinterlegt ist. Diese Domain wird nicht zum Versenden von E-Mails genutzt und auch keine anderweitige Verbindung aufgebaut.", "mailDomainNameHelpText": "Dieser Domain Name wird genutzt, wenn für einen User eine E-Mail-Adresse benötigt wird, für den keine hinterlegt ist. Diese Domain wird nicht zum Versenden von E-Mails genutzt und auch keine anderweitige Verbindung aufgebaut.",
"enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.", "enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.",

View File

@@ -49,7 +49,7 @@
"install": "Install {{name}} Plugin", "install": "Install {{name}} Plugin",
"update": "Update {{name}} Plugin", "update": "Update {{name}} Plugin",
"uninstall": "Uninstall {{name}} Plugin", "uninstall": "Uninstall {{name}} Plugin",
"cloudoguInstall": "Get plugin from myCloudogu" "cloudoguInstall": "Install {{name}} Plugin"
}, },
"restart": "Restart to make plugin changes effective", "restart": "Restart to make plugin changes effective",
"install": "Install", "install": "Install",
@@ -69,8 +69,8 @@
"version": "Version", "version": "Version",
"currentVersion": "Installed version", "currentVersion": "Installed version",
"newVersion": "New version", "newVersion": "New version",
"cloudoguInstallInfo": "This plugin is exclusively available via myCloudogu. Follow the instructions to install it.", "cloudoguInstallInfo": "Connect your SCM-Manager instance with your myCloudogu account to access myCloudogu plugins. If you do not already have an account you can easily register for free.",
"cloudoguInstall": "To Installation Instructions", "cloudoguInstall": "Connect myCloudogu and install",
"dependencyNotification": "With this plugin, the following dependencies will be installed/updated if their latest versions are not installed yet!", "dependencyNotification": "With this plugin, the following dependencies will be installed/updated if their latest versions are not installed yet!",
"optionalDependencyNotification": "With this plugin, the following optional dependencies will be updated if they are installed!", "optionalDependencyNotification": "With this plugin, the following optional dependencies will be updated if they are installed!",
"dependencies": "Dependencies", "dependencies": "Dependencies",
@@ -85,6 +85,15 @@
"updateAllInfo": "The following plugin changes will be executed. You need to restart the SCM-Manager to make these changes effective.", "updateAllInfo": "The following plugin changes will be executed. You need to restart the SCM-Manager to make these changes effective.",
"manualRestartRequired": "After the plugin change has been made, SCM-Manager must be restarted.", "manualRestartRequired": "After the plugin change has been made, SCM-Manager must be restarted.",
"showPending": "To execute the following plugin changes, SCM-Manager must be restarted." "showPending": "To execute the following plugin changes, SCM-Manager must be restarted."
},
"myCloudogu": {
"connectionInfo": "Instance is connected to myCloudogu.\nAccount: {{pluginCenterSubject}}",
"login": {
"button": {
"label": "Connect to <0>myCloudogu</0>"
},
"description": "Connect your SCM-Manager with <0>myCloudogu</0> to install special plugins. myCloudogu is the home of the SCM-Manager Community, sustained by the maintainers of the SCM-Manager. You don't have an account yet? Create a free myCloudogu account while connecting your SCM-Manager instance."
}
} }
}, },
"repositoryRole": { "repositoryRole": {

View File

@@ -11,6 +11,19 @@
"no-write-permission-notification": "Please note: You do not have the permission to edit the config!" "no-write-permission-notification": "Please note: You do not have the permission to edit the config!"
} }
}, },
"pluginSettings": {
"subtitle": "Plugin Settings",
"pluginUrl": "Plugin Center URL",
"pluginAuthUrl": "Plugin Center Authentication URL",
"auth": {
"loading": "Loading authentication info ...",
"notAuthenticated": "Plugin Center is not authenticated",
"authenticate": "Authenticate",
"authenticated": "Plugin Center is authenticated as <0 />",
"subjectTooltip": "Authenticated by {{ principal }} {{ ago }}",
"logout": "Logout"
}
},
"proxySettings": { "proxySettings": {
"subtitle": "Proxy Settings", "subtitle": "Proxy Settings",
"enable": "Enable Proxy", "enable": "Enable Proxy",
@@ -54,7 +67,6 @@
"off": "Disabled" "off": "Disabled"
}, },
"skip-failed-authenticators": "Skip Failed Authenticators", "skip-failed-authenticators": "Skip Failed Authenticators",
"plugin-url": "Plugin Center URL",
"release-feed-url": "Release Feed URL", "release-feed-url": "Release Feed URL",
"mail-domain-name": "Fallback Mail Domain Name", "mail-domain-name": "Fallback Mail Domain Name",
"enabled-xsrf-protection": "Enabled XSRF Protection", "enabled-xsrf-protection": "Enabled XSRF Protection",
@@ -79,6 +91,7 @@
"realmDescriptionHelpText": "Enter authentication realm description.", "realmDescriptionHelpText": "Enter authentication realm description.",
"dateFormatHelpText": "Moments date format. Please have a look at the MomentJS documentation.", "dateFormatHelpText": "Moments date format. Please have a look at the MomentJS documentation.",
"pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", "pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture",
"pluginAuthUrlHelpText": "The url of the Plugin Center authentication API.",
"releaseFeedUrlHelpText": "The url of the RSS Release Feed for SCM-Manager. This provides up-to-date version information. To disable this feature just leave the url blank.", "releaseFeedUrlHelpText": "The url of the RSS Release Feed for SCM-Manager. This provides up-to-date version information. To disable this feature just leave the url blank.",
"mailDomainNameHelpText": "This domain name will be used to create email addresses for users without one when needed. It will not be used to send mails nor will be accessed otherwise.", "mailDomainNameHelpText": "This domain name will be used to create email addresses for users without one when needed. It will not be used to send mails nor will be accessed otherwise.",
"enableForwardingHelpText": "Enable mod_proxy port forwarding.", "enableForwardingHelpText": "Enable mod_proxy port forwarding.",

View File

@@ -29,6 +29,7 @@ import ProxySettings from "./ProxySettings";
import GeneralSettings from "./GeneralSettings"; import GeneralSettings from "./GeneralSettings";
import BaseUrlSettings from "./BaseUrlSettings"; import BaseUrlSettings from "./BaseUrlSettings";
import LoginAttempt from "./LoginAttempt"; import LoginAttempt from "./LoginAttempt";
import PluginSettings from "./PluginSettings";
type Props = { type Props = {
submitForm: (p: Config) => void; submitForm: (p: Config) => void;
@@ -65,6 +66,7 @@ const ConfigForm: FC<Props> = ({
proxyExcludes: [], proxyExcludes: [],
skipFailedAuthenticators: false, skipFailedAuthenticators: false,
pluginUrl: "", pluginUrl: "",
pluginAuthUrl: "",
loginAttemptLimitTimeout: 0, loginAttemptLimitTimeout: 0,
enabledXsrfProtection: true, enabledXsrfProtection: true,
enabledUserConverter: false, enabledUserConverter: false,
@@ -142,7 +144,6 @@ const ConfigForm: FC<Props> = ({
dateFormat={innerConfig.dateFormat} dateFormat={innerConfig.dateFormat}
anonymousMode={innerConfig.anonymousMode} anonymousMode={innerConfig.anonymousMode}
skipFailedAuthenticators={innerConfig.skipFailedAuthenticators} skipFailedAuthenticators={innerConfig.skipFailedAuthenticators}
pluginUrl={innerConfig.pluginUrl}
releaseFeedUrl={innerConfig.releaseFeedUrl} releaseFeedUrl={innerConfig.releaseFeedUrl}
mailDomainName={innerConfig.mailDomainName} mailDomainName={innerConfig.mailDomainName}
enabledXsrfProtection={innerConfig.enabledXsrfProtection} enabledXsrfProtection={innerConfig.enabledXsrfProtection}
@@ -168,6 +169,13 @@ const ConfigForm: FC<Props> = ({
hasUpdatePermission={configUpdatePermission} hasUpdatePermission={configUpdatePermission}
/> />
<hr /> <hr />
<PluginSettings
pluginUrl={innerConfig.pluginUrl}
pluginAuthUrl={innerConfig.pluginAuthUrl}
onChange={(isValid, changedValue, name) => onChange(isValid, changedValue, name)}
hasUpdatePermission={configUpdatePermission}
/>
<hr />
<ProxySettings <ProxySettings
proxyPassword={innerConfig.proxyPassword ? innerConfig.proxyPassword : ""} proxyPassword={innerConfig.proxyPassword ? innerConfig.proxyPassword : ""}
proxyPort={innerConfig.proxyPort ? innerConfig.proxyPort : 0} proxyPort={innerConfig.proxyPort ? innerConfig.proxyPort : 0}

View File

@@ -41,7 +41,6 @@ type Props = {
dateFormat: string; dateFormat: string;
anonymousMode: AnonymousMode; anonymousMode: AnonymousMode;
skipFailedAuthenticators: boolean; skipFailedAuthenticators: boolean;
pluginUrl: string;
releaseFeedUrl: string; releaseFeedUrl: string;
mailDomainName: string; mailDomainName: string;
enabledXsrfProtection: boolean; enabledXsrfProtection: boolean;
@@ -58,7 +57,6 @@ const GeneralSettings: FC<Props> = ({
realmDescription, realmDescription,
loginInfoUrl, loginInfoUrl,
anonymousMode, anonymousMode,
pluginUrl,
releaseFeedUrl, releaseFeedUrl,
mailDomainName, mailDomainName,
enabledXsrfProtection, enabledXsrfProtection,
@@ -91,9 +89,6 @@ const GeneralSettings: FC<Props> = ({
const handleNamespaceStrategyChange = (value: string) => { const handleNamespaceStrategyChange = (value: string) => {
onChange(true, value, "namespaceStrategy"); onChange(true, value, "namespaceStrategy");
}; };
const handlePluginCenterUrlChange = (value: string) => {
onChange(true, value, "pluginUrl");
};
const handleReleaseFeedUrlChange = (value: string) => { const handleReleaseFeedUrlChange = (value: string) => {
onChange(true, value, "releaseFeedUrl"); onChange(true, value, "releaseFeedUrl");
}; };
@@ -163,21 +158,13 @@ const GeneralSettings: FC<Props> = ({
</div> </div>
</div> </div>
<div className="columns"> <div className="columns">
<div className="column is-half">
<InputField
label={t("general-settings.plugin-url")}
onChange={handlePluginCenterUrlChange}
value={pluginUrl}
disabled={!hasUpdatePermission}
helpText={t("help.pluginUrlHelpText")}
/>
</div>
<div className="column is-half"> <div className="column is-half">
<Select <Select
label={t("general-settings.anonymousMode.title")} label={t("general-settings.anonymousMode.title")}
onChange={handleAnonymousMode} onChange={handleAnonymousMode}
value={anonymousMode} value={anonymousMode}
disabled={!hasUpdatePermission} disabled={!hasUpdatePermission}
className="is-fullwidth"
options={[ options={[
{ label: t("general-settings.anonymousMode.full"), value: "FULL" }, { label: t("general-settings.anonymousMode.full"), value: "FULL" },
{ label: t("general-settings.anonymousMode.protocolOnly"), value: "PROTOCOL_ONLY" }, { label: t("general-settings.anonymousMode.protocolOnly"), value: "PROTOCOL_ONLY" },
@@ -187,17 +174,6 @@ const GeneralSettings: FC<Props> = ({
testId={"anonymous-mode-select"} testId={"anonymous-mode-select"}
/> />
</div> </div>
</div>
<div className="columns">
<div className="column is-half">
<InputField
label={t("general-settings.release-feed-url")}
onChange={handleReleaseFeedUrlChange}
value={releaseFeedUrl}
disabled={!hasUpdatePermission}
helpText={t("help.releaseFeedUrlHelpText")}
/>
</div>
<div className="column is-half"> <div className="column is-half">
<Checkbox <Checkbox
label={t("general-settings.enabled-user-converter")} label={t("general-settings.enabled-user-converter")}
@@ -230,6 +206,17 @@ const GeneralSettings: FC<Props> = ({
/> />
</div> </div>
</div> </div>
<div className="columns">
<div className="column">
<InputField
label={t("general-settings.release-feed-url")}
onChange={handleReleaseFeedUrlChange}
value={releaseFeedUrl}
disabled={!hasUpdatePermission}
helpText={t("help.releaseFeedUrlHelpText")}
/>
</div>
</div>
<div className="columns"> <div className="columns">
<div className="column is-full"> <div className="column is-full">
<MemberNameTagGroup <MemberNameTagGroup

View File

@@ -0,0 +1,124 @@
/*
* 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 { usePluginCenterAuthInfo, usePluginCenterLogout } from "@scm-manager/ui-api";
import { Button, ErrorNotification, Notification, Tooltip, useDateFormatter } from "@scm-manager/ui-components";
import { Link, PluginCenterAuthenticationInfo } from "@scm-manager/ui-types";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import { Trans, useTranslation } from "react-i18next";
const Message = styled.p`
line-height: 2.5rem;
`;
type Props = {
authenticationInfo: PluginCenterAuthenticationInfo;
};
const PluginCenterSubject: FC<Props> = ({ authenticationInfo }) => {
const formatter = useDateFormatter({ date: authenticationInfo.date });
const [t] = useTranslation("config");
return (
<>
<Tooltip
location="top"
message={t("pluginSettings.auth.subjectTooltip", {
principal: authenticationInfo.principal,
ago: formatter?.formatDistance()
})}
>
<strong>{authenticationInfo.pluginCenterSubject}</strong>
</Tooltip>
</>
);
};
const AuthenticatedInfo: FC<Props> = ({ authenticationInfo }) => {
const { logout, isLoading, error } = usePluginCenterLogout(authenticationInfo);
const [t] = useTranslation("config");
const subject = <PluginCenterSubject authenticationInfo={authenticationInfo} />;
return (
<Notification type="inherit">
<div className="is-full-width is-flex is-justify-content-space-between is-align-content-center">
<Message>
<Trans t={t} i18nKey="pluginSettings.auth.authenticated" components={[subject]} />
</Message>
{authenticationInfo._links.logout ? (
<Button color="warning" loading={isLoading} action={logout}>
{t("pluginSettings.auth.logout")}
</Button>
) : null}
</div>
{error ? (
<div className="pt-4">
<ErrorNotification error={error} />
</div>
) : null}
</Notification>
);
};
const PluginCenterAuthentication: FC = () => {
const { data, isLoading, error } = usePluginCenterAuthInfo();
const location = useLocation();
const [t] = useTranslation("config");
if (isLoading) {
return (
<div className="is-flex is-align-content-center">
<span className="small-loading-spinner pt-1 pr-3" />
<p>{t("pluginSettings.auth.loading")}</p>
</div>
);
}
if (error) {
return <ErrorNotification error={error} />;
}
if (!data) {
return null;
}
if (data.principal) {
return <AuthenticatedInfo authenticationInfo={data} />;
}
return (
<Notification type="inherit" className="is-flex is-justify-content-space-between is-align-content-center">
<Message>{t("pluginSettings.auth.notAuthenticated")}</Message>
{data._links.login ? (
<Button color="primary" link={(data._links.login as Link).href + "?source=" + location.pathname}>
{t("pluginSettings.auth.authenticate")}
</Button>
) : null}
</Notification>
);
};
export default PluginCenterAuthentication;

View File

@@ -0,0 +1,75 @@
/*
* 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 { InputField, Subtitle } from "@scm-manager/ui-components";
import PluginCenterAuthentication from "./PluginCenterAuthentication";
type Props = {
pluginUrl: string;
pluginAuthUrl: string;
onChange: (isValid: boolean, changedValue: string, name: string) => void;
hasUpdatePermission: boolean;
};
const PluginSettings: FC<Props> = ({ pluginUrl, pluginAuthUrl, onChange, hasUpdatePermission }) => {
const { t } = useTranslation("config");
const handlePluginCenterUrlChange = (value: string) => {
onChange(true, value, "pluginUrl");
};
const handlePluginCenterAuthUrlChange = (value: string) => {
onChange(true, value, "pluginAuthUrl");
};
return (
<div>
<Subtitle subtitle={t("pluginSettings.subtitle")} />
<div className="columns">
<div className="column is-half">
<InputField
label={t("pluginSettings.pluginUrl")}
onChange={handlePluginCenterUrlChange}
value={pluginUrl}
disabled={!hasUpdatePermission}
helpText={t("help.pluginUrlHelpText")}
/>
</div>
<div className="column is-half">
<InputField
label={t("pluginSettings.pluginAuthUrl")}
onChange={handlePluginCenterAuthUrlChange}
value={pluginAuthUrl}
disabled={!hasUpdatePermission}
helpText={t("help.pluginAuthUrlHelpText")}
/>
</div>
</div>
<PluginCenterAuthentication />
</div>
);
};
export default PluginSettings;

View File

@@ -0,0 +1,61 @@
/*
* 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 { Button } from "@scm-manager/ui-components";
import * as React from "react";
import { FC } from "react";
import styled from "styled-components";
import { Trans, useTranslation } from "react-i18next";
const MyCloudoguBannerWrapper = styled.div`
border: 1px solid;
`;
type Props = {
loginLink?: string;
};
const MyCloudoguBanner: FC<Props> = ({ loginLink }) => {
const [t] = useTranslation("admin");
return loginLink ? (
<MyCloudoguBannerWrapper className="has-rounded-border is-flex is-flex-direction-column is-align-items-center p-5 mb-4 has-border-success">
<Button className="mb-5 has-text-weight-normal has-border-info" reducedMobile={true} link={loginLink}>
<Trans
t={t}
i18nKey="plugins.myCloudogu.login.button.label"
components={[<span className="mx-1 has-text-info">myCloudogu</span>]}
/>
</Button>
<p className="is-align-self-flex-start is-size-7">
<Trans
t={t}
i18nKey="plugins.myCloudogu.login.description"
components={[<a href="https://my.cloudogu.com/">myCloudogu</a>]}
/>
</p>
</MyCloudoguBannerWrapper>
) : null;
};
export default MyCloudoguBanner;

View File

@@ -0,0 +1,37 @@
/*
* 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 from "react";
import styled from "styled-components";
const MyCloudoguTagWrapper = styled.span`
border: solid 1px;
`;
const MyCloudoguTag = () => (
<MyCloudoguTagWrapper className="has-text-info has-border-info has-rounded-border p-1 is-size-7">
myCloudogu
</MyCloudoguTagWrapper>
);
export default MyCloudoguTag;

View File

@@ -23,15 +23,18 @@
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import styled from "styled-components"; import styled from "styled-components";
import { Link, Plugin } from "@scm-manager/ui-types"; import { Link, Plugin, PluginCenterAuthenticationInfo } from "@scm-manager/ui-types";
import { CardColumn, Icon } from "@scm-manager/ui-components"; import { CardColumn, Icon } from "@scm-manager/ui-components";
import { PluginAction, PluginModalContent } from "../containers/PluginsOverview"; import { PluginAction, PluginModalContent } from "../containers/PluginsOverview";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import PluginAvatar from "./PluginAvatar"; import PluginAvatar from "./PluginAvatar";
import classNames from "classnames";
import MyCloudoguTag from "./MyCloudoguTag";
type Props = { type Props = {
plugin: Plugin; plugin: Plugin;
openModal: (content: PluginModalContent) => void; openModal: (content: PluginModalContent) => void;
pluginCenterAuthInfo?: PluginCenterAuthenticationInfo;
}; };
const ActionbarWrapper = styled.div` const ActionbarWrapper = styled.div`
@@ -59,19 +62,20 @@ const IconWrapper: FC<{ action: () => void }> = ({ action, children }) => {
); );
}; };
const PluginEntry: FC<Props> = ({ plugin, openModal }) => { const PluginEntry: FC<Props> = ({ plugin, openModal, pluginCenterAuthInfo }) => {
const [t] = useTranslation("admin"); const [t] = useTranslation("admin");
const isInstallable = plugin._links.install && (plugin._links.install as Link).href; const isInstallable = plugin._links.install && (plugin._links.install as Link).href;
const isUpdatable = plugin._links.update && (plugin._links.update as Link).href; const isUpdatable = plugin._links.update && (plugin._links.update as Link).href;
const isUninstallable = plugin._links.uninstall && (plugin._links.uninstall as Link).href; const isUninstallable = plugin._links.uninstall && (plugin._links.uninstall as Link).href;
const isCloudoguPlugin = plugin.type === "CLOUDOGU"; const isCloudoguPlugin = plugin.type === "CLOUDOGU";
const isDefaultPluginCenterLoginAvailable = pluginCenterAuthInfo?.default && !!pluginCenterAuthInfo?._links?.login;
const evaluateAction = () => { const evaluateAction = () => {
if (isInstallable) { if (isInstallable) {
return () => openModal({ plugin, action: PluginAction.INSTALL }); return () => openModal({ plugin, action: PluginAction.INSTALL });
} }
if (isCloudoguPlugin) { if (isCloudoguPlugin && isDefaultPluginCenterLoginAvailable) {
return () => openModal({ plugin, action: PluginAction.CLOUDOGU }); return () => openModal({ plugin, action: PluginAction.CLOUDOGU });
} }
@@ -88,7 +92,7 @@ const PluginEntry: FC<Props> = ({ plugin, openModal }) => {
); );
const actionBar = () => ( const actionBar = () => (
<ActionbarWrapper className="is-flex"> <ActionbarWrapper className="is-flex">
{isCloudoguPlugin && ( {isCloudoguPlugin && isDefaultPluginCenterLoginAvailable && (
<IconWrapper action={() => openModal({ plugin, action: PluginAction.CLOUDOGU })}> <IconWrapper action={() => openModal({ plugin, action: PluginAction.CLOUDOGU })}>
<Icon title={t("plugins.modal.cloudoguInstall")} name="link" color="success-dark" /> <Icon title={t("plugins.modal.cloudoguInstall")} name="link" color="success-dark" />
</IconWrapper> </IconWrapper>
@@ -120,8 +124,17 @@ const PluginEntry: FC<Props> = ({ plugin, openModal }) => {
description={plugin.description} description={plugin.description}
contentRight={plugin.pending || plugin.markedForUninstall ? pendingSpinner() : actionBar()} contentRight={plugin.pending || plugin.markedForUninstall ? pendingSpinner() : actionBar()}
footerLeft={<small>{plugin.version}</small>} footerLeft={<small>{plugin.version}</small>}
footerRight={<small className="level-item is-block shorten-text">{plugin.author}</small>} footerRight={null}
/> />
<div
className={classNames("is-flex", {
"is-justify-content-space-between": isCloudoguPlugin,
"is-justify-content-end": !isCloudoguPlugin
})}
>
{isCloudoguPlugin ? <MyCloudoguTag /> : null}
<small className="level-item is-block shorten-text is-align-self-flex-end">{plugin.author}</small>
</div>
</> </>
); );
}; };

View File

@@ -23,18 +23,26 @@
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { CardColumnGroup } from "@scm-manager/ui-components"; import { CardColumnGroup } from "@scm-manager/ui-components";
import { PluginGroup } from "@scm-manager/ui-types"; import { PluginCenterAuthenticationInfo, PluginGroup } from "@scm-manager/ui-types";
import PluginEntry from "./PluginEntry"; import PluginEntry from "./PluginEntry";
import { PluginModalContent } from "../containers/PluginsOverview"; import { PluginModalContent } from "../containers/PluginsOverview";
type Props = { type Props = {
group: PluginGroup; group: PluginGroup;
openModal: (content: PluginModalContent) => void; openModal: (content: PluginModalContent) => void;
pluginCenterAuthInfo?: PluginCenterAuthenticationInfo;
}; };
const PluginGroupEntry: FC<Props> = ({ openModal, group }) => { const PluginGroupEntry: FC<Props> = ({ openModal, group, pluginCenterAuthInfo }) => {
const entries = group.plugins.map(plugin => { const entries = group.plugins.map(plugin => {
return <PluginEntry plugin={plugin} openModal={openModal} key={plugin.name} />; return (
<PluginEntry
plugin={plugin}
openModal={openModal}
key={plugin.name}
pluginCenterAuthInfo={pluginCenterAuthInfo}
/>
);
}); });
return <CardColumnGroup name={group.name} elements={entries} />; return <CardColumnGroup name={group.name} elements={entries} />;
}; };

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { Plugin } from "@scm-manager/ui-types"; import { Plugin, PluginCenterAuthenticationInfo } from "@scm-manager/ui-types";
import PluginGroupEntry from "../components/PluginGroupEntry"; import PluginGroupEntry from "../components/PluginGroupEntry";
import groupByCategory from "./groupByCategory"; import groupByCategory from "./groupByCategory";
import { PluginModalContent } from "../containers/PluginsOverview"; import { PluginModalContent } from "../containers/PluginsOverview";
@@ -30,14 +30,22 @@ import { PluginModalContent } from "../containers/PluginsOverview";
type Props = { type Props = {
plugins: Plugin[]; plugins: Plugin[];
openModal: (content: PluginModalContent) => void; openModal: (content: PluginModalContent) => void;
pluginCenterAuthInfo?: PluginCenterAuthenticationInfo;
}; };
const PluginList: FC<Props> = ({ plugins, openModal }) => { const PluginList: FC<Props> = ({ plugins, openModal, pluginCenterAuthInfo }) => {
const groups = groupByCategory(plugins); const groups = groupByCategory(plugins);
return ( return (
<div className="content is-plugin-page"> <div className="content is-plugin-page">
{groups.map(group => { {groups.map(group => {
return <PluginGroupEntry group={group} openModal={openModal} key={group.name} />; return (
<PluginGroupEntry
group={group}
openModal={openModal}
key={group.name}
pluginCenterAuthInfo={pluginCenterAuthInfo}
/>
);
})} })}
</div> </div>
); );

View File

@@ -28,8 +28,9 @@ import styled from "styled-components";
import { Link, Plugin } from "@scm-manager/ui-types"; import { Link, Plugin } from "@scm-manager/ui-types";
import { Button, ButtonGroup, Checkbox, ErrorNotification, Modal, Notification } from "@scm-manager/ui-components"; import { Button, ButtonGroup, Checkbox, ErrorNotification, Modal, Notification } from "@scm-manager/ui-components";
import SuccessNotification from "./SuccessNotification"; import SuccessNotification from "./SuccessNotification";
import { useInstallPlugin, useUninstallPlugin, useUpdatePlugins } from "@scm-manager/ui-api"; import { useInstallPlugin, usePluginCenterAuthInfo, useUninstallPlugin, useUpdatePlugins } from "@scm-manager/ui-api";
import { PluginAction } from "../containers/PluginsOverview"; import { PluginAction } from "../containers/PluginsOverview";
import MyCloudoguTag from "./MyCloudoguTag";
type Props = { type Props = {
plugin: Plugin; plugin: Plugin;
@@ -54,11 +55,16 @@ const ListChild = styled.div`
const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => { const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => {
const [t] = useTranslation("admin"); const [t] = useTranslation("admin");
const [shouldRestart, setShouldRestart] = useState<boolean>(false); const [shouldRestart, setShouldRestart] = useState<boolean>(false);
const {
data: pluginCenterAuthInfo,
isLoading: isLoadingPluginCenterAuthInfo,
error: pluginCenterAuthInfoError
} = usePluginCenterAuthInfo();
const { isLoading: isInstalling, error: installError, install, isInstalled } = useInstallPlugin(); const { isLoading: isInstalling, error: installError, install, isInstalled } = useInstallPlugin();
const { isLoading: isUninstalling, error: uninstallError, uninstall, isUninstalled } = useUninstallPlugin(); const { isLoading: isUninstalling, error: uninstallError, uninstall, isUninstalled } = useUninstallPlugin();
const { isLoading: isUpdating, error: updateError, update, isUpdated } = useUpdatePlugins(); const { isLoading: isUpdating, error: updateError, update, isUpdated } = useUpdatePlugins();
const error = installError || uninstallError || updateError; const error = installError || uninstallError || updateError || pluginCenterAuthInfoError;
const loading = isInstalling || isUninstalling || isUpdating; const loading = isInstalling || isUninstalling || isUpdating || isLoadingPluginCenterAuthInfo;
const isDone = isInstalled || isUninstalled || isUpdated; const isDone = isInstalled || isUninstalled || isUpdated;
useEffect(() => { useEffect(() => {
@@ -71,7 +77,7 @@ const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => {
e.preventDefault(); e.preventDefault();
switch (pluginAction) { switch (pluginAction) {
case PluginAction.CLOUDOGU: case PluginAction.CLOUDOGU:
window.open((plugin._links.cloudoguInstall as Link).href, "_blank"); window.open((pluginCenterAuthInfo?._links?.login as Link).href, "_self");
break; break;
case PluginAction.INSTALL: case PluginAction.INSTALL:
install(plugin, { restart: shouldRestart }); install(plugin, { restart: shouldRestart });
@@ -198,11 +204,16 @@ const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => {
<ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.author}</ListChild> <ListChild className={classNames("field-body", "is-inline-flex")}>{plugin.author}</ListChild>
</div> </div>
{pluginAction === PluginAction.CLOUDOGU && ( {pluginAction === PluginAction.CLOUDOGU && (
<div className="field is-horizontal"> <>
<Notification type="info" className="is-full-width"> <div className="field is-horizontal">
{t("plugins.modal.cloudoguInstallInfo")} <MyCloudoguTag />
</Notification> </div>
</div> <div className="field is-horizontal">
<Notification type="info" className="is-full-width">
{t("plugins.modal.cloudoguInstallInfo")}
</Notification>
</div>
</>
)} )}
{pluginAction === PluginAction.INSTALL && ( {pluginAction === PluginAction.INSTALL && (
<div className="field is-horizontal"> <div className="field is-horizontal">

View File

@@ -24,15 +24,17 @@
import * as React from "react"; import * as React from "react";
import { FC, useState } from "react"; import { FC, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plugin } from "@scm-manager/ui-types"; import { Link, Plugin } from "@scm-manager/ui-types";
import { import {
Button, Button,
ButtonGroup, ButtonGroup,
ErrorNotification, ErrorNotification,
Icon,
Loading, Loading,
Notification, Notification,
Subtitle, Subtitle,
Title Title,
Tooltip
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import PluginsList from "../components/PluginList"; import PluginsList from "../components/PluginList";
import PluginTopActions from "../components/PluginTopActions"; import PluginTopActions from "../components/PluginTopActions";
@@ -41,8 +43,14 @@ import ExecutePendingActionModal from "../components/ExecutePendingActionModal";
import CancelPendingActionModal from "../components/CancelPendingActionModal"; import CancelPendingActionModal from "../components/CancelPendingActionModal";
import UpdateAllActionModal from "../components/UpdateAllActionModal"; import UpdateAllActionModal from "../components/UpdateAllActionModal";
import ShowPendingModal from "../components/ShowPendingModal"; import ShowPendingModal from "../components/ShowPendingModal";
import { useAvailablePlugins, useInstalledPlugins, usePendingPlugins } from "@scm-manager/ui-api"; import {
useAvailablePlugins,
useInstalledPlugins,
usePendingPlugins,
usePluginCenterAuthInfo
} from "@scm-manager/ui-api";
import PluginModal from "../components/PluginModal"; import PluginModal from "../components/PluginModal";
import MyCloudoguBanner from "../components/MyCloudoguBanner";
export enum PluginAction { export enum PluginAction {
INSTALL = "install", INSTALL = "install",
@@ -73,20 +81,43 @@ const PluginsOverview: FC<Props> = ({ installed }) => {
error: installedPluginsError error: installedPluginsError
} = useInstalledPlugins({ enabled: installed }); } = useInstalledPlugins({ enabled: installed });
const { data: pendingPlugins, isLoading: isLoadingPendingPlugins, error: pendingPluginsError } = usePendingPlugins(); const { data: pendingPlugins, isLoading: isLoadingPendingPlugins, error: pendingPluginsError } = usePendingPlugins();
const {
data: pluginCenterAuthInfo,
isLoading: isLoadingPluginCenterAuthInfo,
error: pluginCenterAuthInfoError
} = usePluginCenterAuthInfo();
const [showPendingModal, setShowPendingModal] = useState(false); const [showPendingModal, setShowPendingModal] = useState(false);
const [showExecutePendingModal, setShowExecutePendingModal] = useState(false); const [showExecutePendingModal, setShowExecutePendingModal] = useState(false);
const [showUpdateAllModal, setShowUpdateAllModal] = useState(false); const [showUpdateAllModal, setShowUpdateAllModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false); const [showCancelModal, setShowCancelModal] = useState(false);
const [pluginModalContent, setPluginModalContent] = useState<PluginModalContent | null>(null); const [pluginModalContent, setPluginModalContent] = useState<PluginModalContent | null>(null);
const collection = installed ? installedPlugins : availablePlugins; const collection = installed ? installedPlugins : availablePlugins;
const error = (installed ? installedPluginsError : availablePluginsError) || pendingPluginsError; const error =
const loading = (installed ? isLoadingInstalledPlugins : isLoadingAvailablePlugins) || isLoadingPendingPlugins; (installed ? installedPluginsError : availablePluginsError) || pendingPluginsError || pluginCenterAuthInfoError;
const loading =
(installed ? isLoadingInstalledPlugins : isLoadingAvailablePlugins) ||
isLoadingPendingPlugins ||
isLoadingPluginCenterAuthInfo;
const isPluginCenterAuthenticated = !!pluginCenterAuthInfo?.pluginCenterSubject;
const isDefaultPluginCenter = pluginCenterAuthInfo?.default;
const renderHeader = (actions: React.ReactNode) => { const renderHeader = (actions: React.ReactNode) => {
return ( return (
<div className="columns"> <div className="columns">
<div className="column"> <div className="column">
<Title title={t("plugins.title")} /> <Title>
{t("plugins.title")}
{isPluginCenterAuthenticated && isDefaultPluginCenter ? (
<Tooltip
message={t("plugins.myCloudogu.connectionInfo", {
pluginCenterSubject: pluginCenterAuthInfo.pluginCenterSubject
})}
multiline={true}
>
<Icon name="check-circle" color="info" className="is-size-5 ml-1" />
</Tooltip>
) : null}
</Title>
<Subtitle subtitle={installed ? t("plugins.installedSubtitle") : t("plugins.availableSubtitle")} /> <Subtitle subtitle={installed ? t("plugins.installedSubtitle") : t("plugins.availableSubtitle")} />
</div> </div>
<PluginTopActions>{actions}</PluginTopActions> <PluginTopActions>{actions}</PluginTopActions>
@@ -165,7 +196,7 @@ const PluginsOverview: FC<Props> = ({ installed }) => {
}; };
const computeUpdateAllSize = () => { const computeUpdateAllSize = () => {
const outdatedPlugins = collection?._embedded.plugins.filter((p: Plugin) => p._links.update).length; const outdatedPlugins = collection?._embedded?.plugins.filter((p: Plugin) => p._links.update).length;
return t("plugins.outdatedPlugins", { return t("plugins.outdatedPlugins", {
count: outdatedPlugins count: outdatedPlugins
}); });
@@ -173,7 +204,13 @@ const PluginsOverview: FC<Props> = ({ installed }) => {
const renderPluginsList = () => { const renderPluginsList = () => {
if (collection?._embedded && collection._embedded.plugins.length > 0) { if (collection?._embedded && collection._embedded.plugins.length > 0) {
return <PluginsList plugins={collection._embedded.plugins} openModal={setPluginModalContent} />; return (
<PluginsList
plugins={collection._embedded.plugins}
openModal={setPluginModalContent}
pluginCenterAuthInfo={pluginCenterAuthInfo}
/>
);
} }
return <Notification type="info">{t("plugins.noPlugins")}</Notification>; return <Notification type="info">{t("plugins.noPlugins")}</Notification>;
}; };
@@ -213,6 +250,9 @@ const PluginsOverview: FC<Props> = ({ installed }) => {
<> <>
{renderHeader(actions)} {renderHeader(actions)}
<hr className="header-with-actions" /> <hr className="header-with-actions" />
{isDefaultPluginCenter ? (
<MyCloudoguBanner loginLink={(pluginCenterAuthInfo?._links?.login as Link)?.href} />
) : null}
{renderPluginsList()} {renderPluginsList()}
{renderFooter(actions)} {renderFooter(actions)}
{renderModals()} {renderModals()}

View File

@@ -0,0 +1,47 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Notification, Page } from "@scm-manager/ui-components";
type LocationState = {
code: string;
};
const ExternalError = () => {
const { code } = useParams<LocationState>();
const [t] = useTranslation(["commons", "plugins"]);
return (
<Page title={t("app.error.title")} subtitle={t(`plugins:errors.${code}.displayName`)}>
<Notification type="danger" role="alert">
{t(`plugins:errors.${code}.description`)}
</Notification>
</Page>
);
};
export default ExternalError;

View File

@@ -50,6 +50,7 @@ import ImportLog from "../repos/importlog/ImportLog";
import CreateRepositoryRoot from "../repos/containers/CreateRepositoryRoot"; import CreateRepositoryRoot from "../repos/containers/CreateRepositoryRoot";
import Search from "../search/Search"; import Search from "../search/Search";
import Syntax from "../search/Syntax"; import Syntax from "../search/Syntax";
import ExternalError from "./ExternalError";
type Props = { type Props = {
me: Me; me: Me;
@@ -76,6 +77,7 @@ const Main: FC<Props> = props => {
<Redirect exact from="/" to={url} /> <Redirect exact from="/" to={url} />
<Route exact path="/login" component={Login} /> <Route exact path="/login" component={Login} />
<Route path="/logout" component={Logout} /> <Route path="/logout" component={Logout} />
<Route path="/error/:code" component={ExternalError} />
<Redirect exact strict from="/repos" to="/repos/" /> <Redirect exact strict from="/repos" to="/repos/" />
<ProtectedRoute exact path="/repos/" component={Overview} authenticated={authenticated} /> <ProtectedRoute exact path="/repos/" component={Overview} authenticated={authenticated} />
<ProtectedRoute path="/repos/create" component={CreateRepositoryRoot} authenticated={authenticated} /> <ProtectedRoute path="/repos/create" component={CreateRepositoryRoot} authenticated={authenticated} />

View File

@@ -54,6 +54,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto {
private Set<String> proxyExcludes; private Set<String> proxyExcludes;
private boolean skipFailedAuthenticators; private boolean skipFailedAuthenticators;
private String pluginUrl; private String pluginUrl;
private String pluginAuthUrl;
private long loginAttemptLimitTimeout; private long loginAttemptLimitTimeout;
private boolean enabledXsrfProtection; private boolean enabledXsrfProtection;
private boolean enabledUserConverter; private boolean enabledUserConverter;

View File

@@ -105,6 +105,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
} }
if (PluginPermissions.read().isPermitted()) { if (PluginPermissions.read().isPermitted()) {
builder.single(link("pluginCenterAuth", resourceLinks.pluginCenterAuth().auth()));
builder.single(link("installedPlugins", resourceLinks.installedPluginCollection().self())); builder.single(link("installedPlugins", resourceLinks.installedPluginCollection().self()));
builder.single(link("availablePlugins", resourceLinks.availablePluginCollection().self())); builder.single(link("availablePlugins", resourceLinks.availablePluginCollection().self()));
} }

View File

@@ -0,0 +1,429 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import sonia.scm.ExceptionWithContext;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.plugin.AuthenticationInfo;
import sonia.scm.plugin.PluginCenterAuthenticator;
import sonia.scm.plugin.PluginPermissions;
import sonia.scm.security.AllowAnonymousAccess;
import sonia.scm.security.Impersonator;
import sonia.scm.security.SecureParameterSerializer;
import sonia.scm.security.XsrfExcludes;
import sonia.scm.user.DisplayUser;
import sonia.scm.user.User;
import sonia.scm.user.UserDisplayManager;
import sonia.scm.util.HttpUtil;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;
import java.util.UUID;
@Singleton
public class PluginCenterAuthResource {
@VisibleForTesting
static final String ERROR_SOURCE_MISSING = "5DSqG6Mcg1";
@VisibleForTesting
static final String ERROR_AUTHENTICATION_DISABLED = "8tSqFDot11";
@VisibleForTesting
static final String ERROR_ALREADY_AUTHENTICATED = "8XSqFEBd41";
@VisibleForTesting
static final String ERROR_PARAMS_MISSING = "52SqQBdpO1";
@VisibleForTesting
static final String ERROR_CHALLENGE_MISSING = "FNSqFKQIR1";
@VisibleForTesting
static final String ERROR_CHALLENGE_DOES_NOT_MATCH = "8ESqFElpI1";
private final ScmPathInfoStore pathInfoStore;
private final PluginCenterAuthenticator authenticator;
private final ScmConfiguration configuration;
private final UserDisplayManager userDisplayManager;
private final XsrfExcludes excludes;
private final ChallengeGenerator challengeGenerator;
private final SecureParameterSerializer parameterSerializer;
private final Impersonator impersonator;
private String challenge;
@Inject
public PluginCenterAuthResource(
ScmPathInfoStore pathInfoStore,
PluginCenterAuthenticator authenticator,
UserDisplayManager userDisplayManager,
ScmConfiguration scmConfiguration,
XsrfExcludes excludes,
SecureParameterSerializer parameterSerializer,
Impersonator impersonator) {
this(
pathInfoStore, authenticator, userDisplayManager, scmConfiguration, excludes, () -> UUID.randomUUID().toString(),
parameterSerializer, impersonator);
}
@VisibleForTesting
PluginCenterAuthResource(
ScmPathInfoStore pathInfoStore,
PluginCenterAuthenticator authenticator,
UserDisplayManager userDisplayManager,
ScmConfiguration configuration,
XsrfExcludes excludes,
ChallengeGenerator challengeGenerator,
SecureParameterSerializer parameterSerializer,
Impersonator impersonator) {
this.pathInfoStore = pathInfoStore;
this.authenticator = authenticator;
this.configuration = configuration;
this.userDisplayManager = userDisplayManager;
this.excludes = excludes;
this.challengeGenerator = challengeGenerator;
this.parameterSerializer = parameterSerializer;
this.impersonator = impersonator;
}
@GET
@Path("")
@Operation(
summary = "Return plugin center auth info",
description = "Return authentication information of plugin center connection",
tags = "Plugin Management",
operationId = "plugin_center_auth_information"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.PLUGIN_COLLECTION,
schema = @Schema(implementation = PluginCenterAuthenticationInfoDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:read\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@Produces(VndMediaType.PLUGIN_CENTER_AUTH_INFO)
public Response authenticationInfo(@Context UriInfo uriInfo) {
Optional<AuthenticationInfo> authentication = authenticator.getAuthenticationInfo();
if (authentication.isPresent()) {
return Response.ok(createAuthenticatedDto(uriInfo, authentication.get())).build();
}
PluginCenterAuthenticationInfoDto dto = new PluginCenterAuthenticationInfoDto(createLinks(uriInfo, false));
dto.setDefault(configuration.isDefaultPluginAuthUrl());
return Response.ok(dto).build();
}
private PluginCenterAuthenticationInfoDto createAuthenticatedDto(@Context UriInfo uriInfo, AuthenticationInfo info) {
PluginCenterAuthenticationInfoDto dto = new PluginCenterAuthenticationInfoDto(
createLinks(uriInfo, true)
);
dto.setPrincipal(getPrincipalDisplayName(info.getPrincipal()));
dto.setPluginCenterSubject(info.getPluginCenterSubject());
dto.setDate(info.getDate());
dto.setDefault(configuration.isDefaultPluginAuthUrl());
return dto;
}
@GET
@Path("login")
@Operation(
summary = "Login",
description = "Start the authentication flow to connect the plugin center with an account",
tags = "Plugin Management",
operationId = "plugin_center_auth_login"
)
@ApiResponse(
responseCode = "303",
description = "See other"
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response login(@Context UriInfo uriInfo, @QueryParam("source") String source) throws IOException {
String pluginAuthUrl = configuration.getPluginAuthUrl();
if (Strings.isNullOrEmpty(source)) {
return error(ERROR_SOURCE_MISSING);
}
if (Strings.isNullOrEmpty(pluginAuthUrl)) {
return error(ERROR_AUTHENTICATION_DISABLED);
}
if (authenticator.isAuthenticated()) {
return error(ERROR_ALREADY_AUTHENTICATED);
}
challenge = challengeGenerator.create();
URI selfUri = uriInfo.getAbsolutePath();
selfUri = selfUri.resolve(selfUri.getPath().replace("/login", "/callback"));
String principal = SecurityUtils.getSubject().getPrincipal().toString();
AuthParameter parameter = new AuthParameter(
principal,
challenge,
source
);
URI callbackUri = UriBuilder.fromUri(selfUri)
.queryParam("params", parameterSerializer.serialize(parameter))
.build();
excludes.add(callbackUri.getPath());
URI authUri = UriBuilder.fromUri(pluginAuthUrl).queryParam("instance", callbackUri.toASCIIString()).build();
return Response.seeOther(authUri).build();
}
private Links createLinks(UriInfo uriInfo, boolean authenticated) {
String self = uriInfo.getAbsolutePath().toASCIIString();
Links.Builder builder = Links.linkingTo().self(self);
if (PluginPermissions.write().isPermitted()) {
if (authenticated) {
builder.single(Link.link("logout", self));
} else {
URI login = uriInfo.getAbsolutePathBuilder().path("login").build();
builder.single(Link.link("login", login.toASCIIString()));
}
}
return builder.build();
}
private String getPrincipalDisplayName(String principal) {
return userDisplayManager.get(principal).map(DisplayUser::getDisplayName).orElse(principal);
}
@DELETE
@Path("")
@Operation(
summary = "Logout",
description = "Start the authentication flow to connect the plugin center with an account",
tags = "Plugin Management",
operationId = "plugin_center_auth_logout"
)
@ApiResponse(
responseCode = "204",
description = "No content"
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response logout() {
authenticator.logout();
return Response.noContent().build();
}
@POST
@Path("callback")
@Operation(
summary = "Finalize authentication",
description = "Callback endpoint for the authentication flow to finalize the authentication",
tags = "Plugin Management",
operationId = "plugin_center_auth_callback"
)
@ApiResponse(
responseCode = "303",
description = "See other"
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@AllowAnonymousAccess
public Response callback(
@Context UriInfo uriInfo,
@QueryParam("params") String encryptedParams,
@FormParam("subject") String subject,
@FormParam("refresh_token") String refreshToken
) throws IOException {
if (Strings.isNullOrEmpty(encryptedParams)) {
return error(ERROR_PARAMS_MISSING);
}
AuthParameter params = parameterSerializer.deserialize(encryptedParams, AuthParameter.class);
Optional<String> error = checkChallenge(params.getChallenge());
if (error.isPresent()) {
return error(error.get());
}
challenge = null;
excludes.remove(uriInfo.getPath());
PrincipalCollection principal = createPrincipalCollection(params);
try (Impersonator.Session session = impersonator.impersonate(principal)) {
authenticator.authenticate(subject, refreshToken);
} catch (ExceptionWithContext ex) {
return error(ex.getCode());
}
return redirect(params.getSource());
}
private PrincipalCollection createPrincipalCollection(AuthParameter params) {
SimplePrincipalCollection principal = new SimplePrincipalCollection(
params.getPrincipal(), "pluginCenterAuth"
);
User user = new User(params.getPrincipal());
principal.add(user, "pluginCenterAuth");
return principal;
}
@GET
@Path("callback")
@Operation(
summary = "Abort authentication",
description = "Callback endpoint for the authentication flow to abort the authentication",
tags = "Plugin Management",
operationId = "plugin_center_auth_callback_abort"
)
@ApiResponse(
responseCode = "303",
description = "See other"
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response callbackAbort(@Context UriInfo uriInfo, @QueryParam("params") String encryptedParams) throws IOException {
if (Strings.isNullOrEmpty(encryptedParams)) {
return error(ERROR_PARAMS_MISSING);
}
AuthParameter params = parameterSerializer.deserialize(encryptedParams, AuthParameter.class);
Optional<String> error = checkChallenge(params.getChallenge());
if (error.isPresent()) {
return error(error.get());
}
challenge = null;
excludes.remove(uriInfo.getPath());
return redirect(params.getSource());
}
private Response error(String code) {
return redirect("error/" + code);
}
private Response redirect(String location) {
URI rootUri = pathInfoStore.get().getRootUri();
String path = rootUri.getPath();
if (!Strings.isNullOrEmpty(location)) {
path = HttpUtil.concatenate(path, location);
}
return redirect(rootUri.resolve(path));
}
private Response redirect(URI location) {
return Response.status(Response.Status.SEE_OTHER).location(location).build();
}
private Optional<String> checkChallenge(String challengeFromRequest) {
if (Strings.isNullOrEmpty(challenge)) {
return Optional.of(ERROR_CHALLENGE_MISSING);
}
if (!challenge.equals(challengeFromRequest)) {
return Optional.of(ERROR_CHALLENGE_DOES_NOT_MATCH);
}
return Optional.empty();
}
@FunctionalInterface
interface ChallengeGenerator {
String create();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
static class AuthParameter {
private String principal;
private String challenge;
private String source;
}
}

View File

@@ -0,0 +1,53 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.annotation.JsonInclude;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.Setter;
import java.time.Instant;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
@Getter
@Setter
@SuppressWarnings("java:S2160") // we need no equals here
public class PluginCenterAuthenticationInfoDto extends HalRepresentation {
@JsonInclude(NON_NULL)
private String principal;
@JsonInclude(NON_NULL)
private String pluginCenterSubject;
@JsonInclude(NON_NULL)
private Instant date;
private boolean isDefault;
public PluginCenterAuthenticationInfoDto(Links links) {
super(links);
}
}

View File

@@ -24,6 +24,7 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.google.common.base.Strings;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import org.mapstruct.Mapper; import org.mapstruct.Mapper;
import org.mapstruct.Mapping; import org.mapstruct.Mapping;
@@ -97,7 +98,9 @@ public abstract class PluginDtoMapper {
if (isCloudoguPlugin) { if (isCloudoguPlugin) {
Optional<String> cloudoguInstallLink = plugin.getDescriptor().getInstallLink(); Optional<String> cloudoguInstallLink = plugin.getDescriptor().getInstallLink();
cloudoguInstallLink.ifPresent(link -> links.single(link("cloudoguInstall", link))); cloudoguInstallLink.ifPresent(link -> links.single(link("cloudoguInstall", link)));
} else { }
if (!Strings.isNullOrEmpty(plugin.getDescriptor().getUrl())) {
String href = resourceLinks.availablePlugin().install(information.getName()); String href = resourceLinks.availablePlugin().install(information.getName());
appendLink(links, "install", href); appendLink(links, "install", href);
} }

View File

@@ -40,12 +40,19 @@ public class PluginRootResource {
private final Provider<InstalledPluginResource> installedPluginResourceProvider; private final Provider<InstalledPluginResource> installedPluginResourceProvider;
private final Provider<AvailablePluginResource> availablePluginResourceProvider; private final Provider<AvailablePluginResource> availablePluginResourceProvider;
private final Provider<PendingPluginResource> pendingPluginResourceProvider; private final Provider<PendingPluginResource> pendingPluginResourceProvider;
private final Provider<PluginCenterAuthResource> pluginCenterAuthResourceProvider;
@Inject @Inject
public PluginRootResource(Provider<InstalledPluginResource> installedPluginResourceProvider, Provider<AvailablePluginResource> availablePluginResourceProvider, Provider<PendingPluginResource> pendingPluginResourceProvider) { public PluginRootResource(
Provider<InstalledPluginResource> installedPluginResourceProvider,
Provider<AvailablePluginResource> availablePluginResourceProvider,
Provider<PendingPluginResource> pendingPluginResourceProvider,
Provider<PluginCenterAuthResource> pluginCenterAuthResourceProvider
) {
this.installedPluginResourceProvider = installedPluginResourceProvider; this.installedPluginResourceProvider = installedPluginResourceProvider;
this.availablePluginResourceProvider = availablePluginResourceProvider; this.availablePluginResourceProvider = availablePluginResourceProvider;
this.pendingPluginResourceProvider = pendingPluginResourceProvider; this.pendingPluginResourceProvider = pendingPluginResourceProvider;
this.pluginCenterAuthResourceProvider = pluginCenterAuthResourceProvider;
} }
@Path("/installed") @Path("/installed")
@@ -58,4 +65,9 @@ public class PluginRootResource {
@Path("/pending") @Path("/pending")
public PendingPluginResource pendingPlugins() { return pendingPluginResourceProvider.get(); } public PendingPluginResource pendingPlugins() { return pendingPluginResourceProvider.get(); }
@Path("/auth")
public PluginCenterAuthResource authResource() {
return pluginCenterAuthResourceProvider.get();
}
} }

View File

@@ -1202,4 +1202,20 @@ class ResourceLinks {
.href(); .href();
} }
} }
public PluginCenterAuthLinks pluginCenterAuth() {
return new PluginCenterAuthLinks(scmPathInfoStore.get().get());
}
static class PluginCenterAuthLinks {
private final LinkBuilder indexLinkBuilder;
PluginCenterAuthLinks(ScmPathInfo pathInfo) {
indexLinkBuilder = new LinkBuilder(pathInfo, PluginRootResource.class, PluginCenterAuthResource.class);
}
String auth() {
return indexLinkBuilder.method("authResource").parameters().method("authenticationInfo").parameters().href();
}
}
} }

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.plugin;
import java.time.Instant;
/**
* Information about the plugin center authentication.
* @since 2.28.0
*/
public interface AuthenticationInfo {
/**
* Returns the username of the SCM-Manager user which has authenticated the plugin center.
* @return SCM-Manager username
*/
String getPrincipal();
/**
* Returns the subject of the plugin center user.
* @return plugin center subject
*/
String getPluginCenterSubject();
/**
* Returns the date on which the authentication was performed.
* @return authentication date
*/
Instant getDate();
}

View File

@@ -0,0 +1,50 @@
/*
* 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.plugin;
import sonia.scm.ExceptionWithContext;
import java.util.Collections;
/**
* Exception is thrown if the exchange of a refresh token to an access token fails.
*
* @since 2.28.0
*/
public class FetchAccessTokenFailedException extends ExceptionWithContext {
public FetchAccessTokenFailedException(String message) {
super(Collections.emptyList(), message);
}
public FetchAccessTokenFailedException(String message, Exception cause) {
super(Collections.emptyList(), message, cause);
}
@Override
public String getCode() {
return "AHSqALeEv1";
}
}

View File

@@ -24,6 +24,8 @@
package sonia.scm.plugin; package sonia.scm.plugin;
import com.github.legman.Subscribe;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
@@ -34,8 +36,10 @@ import sonia.scm.util.HttpUtil;
import sonia.scm.util.SystemUtil; import sonia.scm.util.SystemUtil;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.Set; import java.util.Set;
@Singleton
public class PluginCenter { public class PluginCenter {
private static final String CACHE_NAME = "sonia.cache.plugins"; private static final String CACHE_NAME = "sonia.cache.plugins";
@@ -55,19 +59,37 @@ public class PluginCenter {
this.cache = cacheManager.getCache(CACHE_NAME); this.cache = cacheManager.getCache(CACHE_NAME);
} }
@Subscribe
public void handle(PluginCenterAuthenticationEvent event) {
LOG.debug("clear plugin center cache, because of {}", event);
cache.clear();
}
synchronized Set<AvailablePlugin> getAvailable() { synchronized Set<AvailablePlugin> getAvailable() {
String url = buildPluginUrl(configuration.getPluginUrl()); String url = buildPluginUrl(configuration.getPluginUrl());
Set<AvailablePlugin> plugins = cache.get(url); Set<AvailablePlugin> plugins = cache.get(url);
if (plugins == null) { if (plugins == null) {
LOG.debug("no cached available plugins found, start fetching"); LOG.debug("no cached available plugins found, start fetching");
plugins = loader.load(url); plugins = fetchAvailablePlugins(url);
cache.put(url, plugins);
} else { } else {
LOG.debug("return available plugins from cache"); LOG.debug("return available plugins from cache");
} }
return plugins; return plugins;
} }
@CanIgnoreReturnValue
private Set<AvailablePlugin> fetchAvailablePlugins(String url) {
Set<AvailablePlugin> plugins = loader.load(url);
cache.put(url, plugins);
return plugins;
}
synchronized void refresh() {
LOG.debug("refresh plugin center cache");
String url = buildPluginUrl(configuration.getPluginUrl());
fetchAvailablePlugins(url);
}
private String buildPluginUrl(String url) { private String buildPluginUrl(String url) {
String os = HttpUtil.encode(SystemUtil.getOS()); String os = HttpUtil.encode(SystemUtil.getOS());
String arch = SystemUtil.getArch(); String arch = SystemUtil.getArch();

View File

@@ -0,0 +1,33 @@
/*
* 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.plugin;
/**
* Marker interface for plugin center authentication events such as login or logout.
* @since 2.28.0
*/
public interface PluginCenterAuthenticationEvent {
}

View File

@@ -0,0 +1,179 @@
/*
* 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.plugin;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Value;
import org.apache.shiro.SecurityUtils;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.event.ScmEventBus;
import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.net.ahc.AdvancedHttpResponse;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.util.HttpUtil;
import sonia.scm.xml.XmlInstantAdapter;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.io.IOException;
import java.time.Instant;
import java.util.Optional;
import static sonia.scm.plugin.Tracing.SPAN_KIND;
@Singleton
public class PluginCenterAuthenticator {
@VisibleForTesting
static final String STORE_NAME = "plugin-center-auth";
private final ConfigurationStore<Authentication> configurationStore;
private final ScmConfiguration scmConfiguration;
private final AdvancedHttpClient advancedHttpClient;
private final ScmEventBus eventBus;
@Inject
public PluginCenterAuthenticator(
ConfigurationStoreFactory configurationStore, ScmConfiguration scmConfiguration,
AdvancedHttpClient advancedHttpClient, ScmEventBus eventBus
) {
this.configurationStore = configurationStore.withType(Authentication.class).withName(STORE_NAME).build();
this.scmConfiguration = scmConfiguration;
this.advancedHttpClient = advancedHttpClient;
this.eventBus = eventBus;
}
public void authenticate(String pluginCenterSubject, String refreshToken) {
Preconditions.checkArgument(!Strings.isNullOrEmpty(pluginCenterSubject), "pluginCenterSubject is required");
Preconditions.checkArgument(!Strings.isNullOrEmpty(refreshToken), "refresh token is required");
// only a user which is able to manage plugins, can authenticate the plugin center
PluginPermissions.write().check();
// check if refresh token is valid
Authentication authentication = new Authentication(principal(), pluginCenterSubject, refreshToken, Instant.now());
fetchAccessToken(authentication);
eventBus.post(new PluginCenterLoginEvent(authentication));
}
public void logout() {
PluginPermissions.write().check();
getAuthenticationInfo().ifPresent(authenticationInfo -> {
eventBus.post(new PluginCenterLogoutEvent(authenticationInfo));
configurationStore.delete();
});
}
public boolean isAuthenticated() {
return getAuthentication().isPresent();
}
public Optional<AuthenticationInfo> getAuthenticationInfo() {
PluginPermissions.read().check();
return getAuthentication().map(a -> a);
}
public String fetchAccessToken() {
PluginPermissions.read().check();
Authentication authentication = getAuthentication()
.orElseThrow(() -> new IllegalStateException("An access token can only be obtained, after a prior authentication"));
return fetchAccessToken(authentication);
}
@CanIgnoreReturnValue
private String fetchAccessToken(Authentication authentication) {
String pluginAuthUrl = scmConfiguration.getPluginAuthUrl();
Preconditions.checkState(!Strings.isNullOrEmpty(pluginAuthUrl), "plugin auth url is not configured");
try {
AdvancedHttpResponse response = advancedHttpClient.post(HttpUtil.concatenate(pluginAuthUrl, "refresh"))
.spanKind(SPAN_KIND)
.jsonContent(new RefreshRequest(authentication.getRefreshToken()))
.request();
if (!response.isSuccessful()) {
throw new FetchAccessTokenFailedException("failed to obtain access token, server returned status code " + response.getStatus());
}
RefreshResponse refresh = response.contentFromJson(RefreshResponse.class);
authentication.setRefreshToken(refresh.getRefreshToken());
configurationStore.set(authentication);
return refresh.getAccessToken();
} catch (IOException ex) {
throw new FetchAccessTokenFailedException("failed to obtain an access token", ex);
}
}
private String principal() {
return SecurityUtils.getSubject().getPrincipal().toString();
}
private Optional<Authentication> getAuthentication() {
return configurationStore.getOptional();
}
@Data
@XmlRootElement
@VisibleForTesting
@AllArgsConstructor
@NoArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
static class Authentication implements AuthenticationInfo {
private String principal;
private String pluginCenterSubject;
private String refreshToken;
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
private Instant date;
}
@Value
public static class RefreshRequest {
@JsonProperty("refresh_token")
String refreshToken;
}
@Data
public static class RefreshResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("refresh_token")
private String refreshToken;
}
}

View File

@@ -42,6 +42,8 @@ public abstract class PluginCenterDtoMapper {
Set<AvailablePlugin> map(PluginCenterDto pluginCenterDto) { Set<AvailablePlugin> map(PluginCenterDto pluginCenterDto) {
Set<AvailablePlugin> plugins = new HashSet<>(); Set<AvailablePlugin> plugins = new HashSet<>();
for (PluginCenterDto.Plugin plugin : pluginCenterDto.getEmbedded().getPlugins()) { for (PluginCenterDto.Plugin plugin : pluginCenterDto.getEmbedded().getPlugins()) {
// plugin center api returns always a download link,
// but for cloudogu plugin without authentication the href is an empty string
String url = plugin.getLinks().get("download").getHref(); String url = plugin.getLinks().get("download").getHref();
String installLink = getInstallLink(plugin); String installLink = getInstallLink(plugin);
AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor(

View File

@@ -29,6 +29,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
import sonia.scm.net.ahc.AdvancedHttpClient; import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.net.ahc.AdvancedHttpRequest;
import javax.inject.Inject; import javax.inject.Inject;
import java.util.Collections; import java.util.Collections;
@@ -41,17 +42,24 @@ class PluginCenterLoader {
private static final Logger LOG = LoggerFactory.getLogger(PluginCenterLoader.class); private static final Logger LOG = LoggerFactory.getLogger(PluginCenterLoader.class);
private final AdvancedHttpClient client; private final AdvancedHttpClient client;
private final PluginCenterAuthenticator authenticator;
private final PluginCenterDtoMapper mapper; private final PluginCenterDtoMapper mapper;
private final ScmEventBus eventBus; private final ScmEventBus eventBus;
@Inject @Inject
public PluginCenterLoader(AdvancedHttpClient client, ScmEventBus eventBus) { public PluginCenterLoader(AdvancedHttpClient client, ScmEventBus eventBus, PluginCenterAuthenticator authenticator) {
this(client, PluginCenterDtoMapper.INSTANCE, eventBus); this(client, authenticator, PluginCenterDtoMapper.INSTANCE, eventBus);
} }
@VisibleForTesting @VisibleForTesting
PluginCenterLoader(AdvancedHttpClient client, PluginCenterDtoMapper mapper, ScmEventBus eventBus) { PluginCenterLoader(
AdvancedHttpClient client,
PluginCenterAuthenticator authenticator,
PluginCenterDtoMapper mapper,
ScmEventBus eventBus
) {
this.client = client; this.client = client;
this.authenticator = authenticator;
this.mapper = mapper; this.mapper = mapper;
this.eventBus = eventBus; this.eventBus = eventBus;
} }
@@ -59,8 +67,11 @@ class PluginCenterLoader {
Set<AvailablePlugin> load(String url) { Set<AvailablePlugin> load(String url) {
try { try {
LOG.info("fetch plugins from {}", url); LOG.info("fetch plugins from {}", url);
PluginCenterDto pluginCenterDto = client.get(url).spanKind(SPAN_KIND).request() AdvancedHttpRequest request = client.get(url).spanKind(SPAN_KIND);
.contentFromJson(PluginCenterDto.class); if (authenticator.isAuthenticated()) {
request.bearerAuth(authenticator.fetchAccessToken());
}
PluginCenterDto pluginCenterDto = request.request().contentFromJson(PluginCenterDto.class);
return mapper.map(pluginCenterDto); return mapper.map(pluginCenterDto);
} catch (Exception ex) { } catch (Exception ex) {
LOG.error("failed to load plugins from plugin center, returning empty list", ex); LOG.error("failed to load plugins from plugin center, returning empty list", ex);

View File

@@ -0,0 +1,38 @@
/*
* 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.plugin;
import lombok.Value;
import sonia.scm.event.Event;
/**
* Event is fired after a successful login to the plugin center.
* @since 2.28.0
*/
@Event
@Value
public class PluginCenterLoginEvent implements PluginCenterAuthenticationEvent {
AuthenticationInfo authenticationInfo;
}

View File

@@ -0,0 +1,38 @@
/*
* 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.plugin;
import lombok.Value;
import sonia.scm.event.Event;
/**
* Event is fired after a successful logout from plugin center.
* @since 2.28.0
*/
@Event
@Value
public class PluginCenterLogoutEvent implements PluginCenterAuthenticationEvent {
AuthenticationInfo priorAuthenticationInfo;
}

View File

@@ -0,0 +1,61 @@
/*
* 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.plugin;
import sonia.scm.EagerSingleton;
import sonia.scm.schedule.Scheduler;
import javax.inject.Inject;
/**
* Refresh plugin center cache and refresh the token of plugin center authentication.
* @since 2.28.0
*/
@Extension
@EagerSingleton
public class PluginCenterRefresh {
@Inject
@SuppressWarnings("java:S1118") // could not hide constructor
public PluginCenterRefresh(Scheduler scheduler) {
scheduler.schedule("42 42 0/6 * * ?", RefreshTask.class);
}
public static class RefreshTask implements Runnable {
private final PluginCenter pluginCenter;
@Inject
public RefreshTask(PluginCenter pluginCenter) {
this.pluginCenter = pluginCenter;
}
@Override
public void run() {
pluginCenter.refresh();
}
}
}

View File

@@ -29,6 +29,7 @@ import com.google.common.hash.Hashing;
import com.google.common.hash.HashingInputStream; import com.google.common.hash.HashingInputStream;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.net.ahc.AdvancedHttpClient; import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.net.ahc.AdvancedHttpRequest;
import javax.inject.Inject; import javax.inject.Inject;
import java.io.IOException; import java.io.IOException;
@@ -40,18 +41,19 @@ import java.util.Optional;
import static sonia.scm.plugin.Tracing.SPAN_KIND; import static sonia.scm.plugin.Tracing.SPAN_KIND;
@SuppressWarnings("UnstableApiUsage") @SuppressWarnings("UnstableApiUsage") // guava hash is marked as unstable
// guava hash is marked as unstable
class PluginInstaller { class PluginInstaller {
private final SCMContextProvider scmContext; private final SCMContextProvider scmContext;
private final AdvancedHttpClient client; private final AdvancedHttpClient client;
private final PluginCenterAuthenticator authenticator;
private final SmpDescriptorExtractor smpDescriptorExtractor; private final SmpDescriptorExtractor smpDescriptorExtractor;
@Inject @Inject
public PluginInstaller(SCMContextProvider scmContext, AdvancedHttpClient client, SmpDescriptorExtractor smpDescriptorExtractor) { public PluginInstaller(SCMContextProvider scmContext, AdvancedHttpClient client, PluginCenterAuthenticator authenticator, SmpDescriptorExtractor smpDescriptorExtractor) {
this.scmContext = scmContext; this.scmContext = scmContext;
this.client = client; this.client = client;
this.authenticator = authenticator;
this.smpDescriptorExtractor = smpDescriptorExtractor; this.smpDescriptorExtractor = smpDescriptorExtractor;
} }
@@ -128,7 +130,11 @@ class PluginInstaller {
} }
private InputStream download(AvailablePlugin plugin) throws IOException { private InputStream download(AvailablePlugin plugin) throws IOException {
return client.get(plugin.getDescriptor().getUrl()).spanKind(SPAN_KIND).request().contentAsStream(); AdvancedHttpRequest request = client.get(plugin.getDescriptor().getUrl()).spanKind(SPAN_KIND);
if (authenticator.isAuthenticated()) {
request.bearerAuth(authenticator.fetchAccessToken());
}
return request.request().contentAsStream();
} }
private Path createFile(AvailablePlugin plugin) throws IOException { private Path createFile(AvailablePlugin plugin) throws IOException {

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.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.inject.Inject;
import java.io.IOException;
public final class SecureParameterSerializer {
private final ObjectMapper mapper;
@Inject
public SecureParameterSerializer(ObjectMapper mapper) {
this.mapper = mapper;
}
public String serialize(Object object) throws IOException {
String json = mapper.writeValueAsString(object);
return CipherUtil.getInstance().encode(json);
}
public <T> T deserialize(String serialized, Class<T> type) throws IOException {
String decoded = CipherUtil.getInstance().decode(serialized);
return mapper.readValue(decoded, type);
}
}

View File

@@ -50,16 +50,18 @@ public class XsrfAccessTokenValidator implements AccessTokenValidator {
); );
private final Provider<HttpServletRequest> requestProvider; private final Provider<HttpServletRequest> requestProvider;
private final XsrfExcludes excludes;
/** /**
* Constructs a new instance. * Constructs a new instance.
* *
* @param requestProvider http request provider * @param requestProvider http request provider
* @param excludes
*/ */
@Inject @Inject
public XsrfAccessTokenValidator(Provider<HttpServletRequest> requestProvider) { public XsrfAccessTokenValidator(Provider<HttpServletRequest> requestProvider, XsrfExcludes excludes) {
this.requestProvider = requestProvider; this.requestProvider = requestProvider;
this.excludes = excludes;
} }
@Override @Override
@@ -67,6 +69,11 @@ public class XsrfAccessTokenValidator implements AccessTokenValidator {
Optional<String> xsrfClaim = accessToken.getCustom(Xsrf.TOKEN_KEY); Optional<String> xsrfClaim = accessToken.getCustom(Xsrf.TOKEN_KEY);
if (xsrfClaim.isPresent()) { if (xsrfClaim.isPresent()) {
HttpServletRequest request = requestProvider.get(); HttpServletRequest request = requestProvider.get();
if (excludes.contains(request.getRequestURI())) {
return true;
}
String xsrfHeaderValue = request.getHeader(Xsrf.HEADER_KEY); String xsrfHeaderValue = request.getHeader(Xsrf.HEADER_KEY);
return ALLOWED_METHOD.contains(request.getMethod().toUpperCase(Locale.ENGLISH)) return ALLOWED_METHOD.contains(request.getMethod().toUpperCase(Locale.ENGLISH))
|| xsrfClaim.get().equals(xsrfHeaderValue); || xsrfClaim.get().equals(xsrfHeaderValue);

View File

@@ -0,0 +1,68 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.security;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import javax.inject.Singleton;
import java.util.HashSet;
import java.util.Set;
/**
* XsrfExcludes can be used to define request uris which are excluded from xsrf validation.
* @since 2.28.0
*/
@Singleton
public class XsrfExcludes {
private final Set<String> excludes = new HashSet<>();
/**
* Exclude the given request uri from xsrf validation.
* @param requestUri request uri
*/
public void add(String requestUri) {
excludes.add(requestUri);
}
/**
* Include prior excluded request uri to xsrf validation.
* @param requestUri request uri
* @return {@code true} is uri was excluded
*/
@CanIgnoreReturnValue
public boolean remove(String requestUri) {
return excludes.remove(requestUri);
}
/**
* Returns {@code true} if the request uri is excluded from xsrf validation.
* @param requestUri request uri
* @return {@code true} if uri is excluded
*/
public boolean contains(String requestUri) {
return excludes.contains(requestUri);
}
}

View File

@@ -366,6 +366,34 @@
"CISPvega31": { "CISPvega31": {
"displayName": "Ungültiger Repository-Typ für Import", "displayName": "Ungültiger Repository-Typ für Import",
"description": "Der Import ist für den gegebenen Repository-Typen nicht möglich." "description": "Der Import ist für den gegebenen Repository-Typen nicht möglich."
},
"5DSqG6Mcg1": {
"displayName": "Fehlender Source Parameter",
"description": "Der Source Parameter wird für die Authentifizierung benötigt."
},
"8tSqFDot11": {
"displayName": "Authentifizierung ist deaktiviert",
"description": "Die Plugin Center Authentifizierung ist deaktiviert."
},
"8XSqFEBd41": {
"displayName": "Authentifizierung bereits durchgeführt",
"description": "Das Plugin Center wurde bereits authentifiziert."
},
"FNSqFKQIR1": {
"displayName": "Fehlender challenge Parameter",
"description": "Die Antwort der Authentifizierung enthält keinen Challenge Parameter."
},
"8ESqFElpI1": {
"displayName": "Falscher challenge Parameter",
"description": "Die Antwort der Authentifizierung enthält einen falschen Challenge Parameter."
},
"AHSqALeEv1": {
"displayName": "Authentifizierung fehlgeschlagen",
"description": "Die Authentifizierung des Plugin Centers ist fehlgeschlagen."
},
"52SqQBdpO1": {
"displayName": "Fehlender params Parameter",
"description": "Die Antwort der Authentifizierung enthält keinen params Parameter."
} }
}, },
"healthCheckFailures": { "healthCheckFailures": {

View File

@@ -374,6 +374,34 @@
"8wSpi62oJ1": { "8wSpi62oJ1": {
"displayName": "Modification failed", "displayName": "Modification failed",
"description": "The modification could not be applied. This can have many reasons, for example a file that could not be moved, invalid file names, etc." "description": "The modification could not be applied. This can have many reasons, for example a file that could not be moved, invalid file names, etc."
},
"5DSqG6Mcg1": {
"displayName": "Source missing",
"description": "The source parameter is missing."
},
"8tSqFDot11": {
"displayName": "Authentication disabled",
"description": "Plugin center authentication is disabled."
},
"8XSqFEBd41": {
"displayName": "Already authenticated",
"description": "The plugin center is already authenticated."
},
"FNSqFKQIR1": {
"displayName": "Challenge missing",
"description": "The callback for the plugin center authentication is missing the challenge parameter."
},
"8ESqFElpI1": {
"displayName": "Challenge mismatch",
"description": "The provided challenge does not match."
},
"AHSqALeEv1": {
"displayName": "Authentication failed",
"description": "Plugin center authentication failed."
},
"52SqQBdpO1": {
"displayName": "Params missing",
"description": "The parameter params is missing."
} }
}, },
"healthChecksFailures": { "healthChecksFailures": {

View File

@@ -93,7 +93,7 @@ class AvailablePluginResourceTest {
@BeforeEach @BeforeEach
void prepareEnvironment() { void prepareEnvironment() {
pluginRootResource = new PluginRootResource(null, availablePluginResourceProvider, null); pluginRootResource = new PluginRootResource(null, availablePluginResourceProvider, null, null);
when(availablePluginResourceProvider.get()).thenReturn(availablePluginResource); when(availablePluginResourceProvider.get()).thenReturn(availablePluginResource);
dispatcher.addSingletonResource(pluginRootResource); dispatcher.addSingletonResource(pluginRootResource);
} }

View File

@@ -24,21 +24,18 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import org.junit.Before; import org.junit.jupiter.api.Test;
import org.junit.Test; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.internal.util.collections.Sets; import org.mockito.internal.util.collections.Sets;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode; import sonia.scm.security.AnonymousMode;
import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals; @ExtendWith(MockitoExtension.class)
import static org.junit.Assert.assertFalse; class ConfigDtoToScmConfigurationMapperTest {
import static org.junit.Assert.assertTrue;
import static org.mockito.MockitoAnnotations.initMocks;
public class ConfigDtoToScmConfigurationMapperTest {
@InjectMocks @InjectMocks
private ConfigDtoToScmConfigurationMapperImpl mapper; private ConfigDtoToScmConfigurationMapperImpl mapper;
@@ -46,53 +43,49 @@ public class ConfigDtoToScmConfigurationMapperTest {
private final String[] expectedExcludes = {"ex", "clude"}; private final String[] expectedExcludes = {"ex", "clude"};
private final String[] expectedUsers = {"trillian", "arthur"}; private final String[] expectedUsers = {"trillian", "arthur"};
@Before
public void init() {
initMocks(this);
}
@Test @Test
public void shouldMapFields() { void shouldMapFields() {
ConfigDto dto = createDefaultDto(); ConfigDto dto = createDefaultDto();
ScmConfiguration config = mapper.map(dto); ScmConfiguration config = mapper.map(dto);
assertEquals("prPw", config.getProxyPassword()); assertThat(config.getProxyPassword()).isEqualTo("prPw");
assertEquals(42, config.getProxyPort()); assertThat(config.getProxyPort()).isEqualTo(42);
assertEquals("srvr", config.getProxyServer()); assertThat(config.getProxyServer()).isEqualTo("srvr");
assertEquals("user", config.getProxyUser()); assertThat(config.getProxyUser()).isEqualTo("user");
assertTrue(config.isEnableProxy()); assertThat(config.isEnableProxy()).isTrue();
assertEquals("realm", config.getRealmDescription()); assertThat(config.getRealmDescription()).isEqualTo("realm");
assertTrue(config.isDisableGroupingGrid()); assertThat(config.isDisableGroupingGrid()).isTrue();
assertEquals("yyyy", config.getDateFormat()); assertThat(config.getDateFormat()).isEqualTo("yyyy");
assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode()); assertThat(config.getAnonymousMode()).isSameAs(AnonymousMode.PROTOCOL_ONLY);
assertEquals("baseurl", config.getBaseUrl()); assertThat(config.getBaseUrl()).isEqualTo("baseurl");
assertTrue(config.isForceBaseUrl()); assertThat(config.isForceBaseUrl()).isTrue();
assertEquals(41, config.getLoginAttemptLimit()); assertThat(config.getLoginAttemptLimit()).isEqualTo(41);
assertTrue("proxyExcludes", config.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes))); assertThat(config.getProxyExcludes()).contains(expectedExcludes);
assertTrue(config.isSkipFailedAuthenticators()); assertThat(config.isSkipFailedAuthenticators()).isTrue();
assertEquals("https://plug.ins", config.getPluginUrl()); assertThat(config.getPluginUrl()).isEqualTo("https://plug.ins");
assertEquals(40, config.getLoginAttemptLimitTimeout()); assertThat(config.getPluginAuthUrl()).isEqualTo("https://plug.ins/oidc");
assertTrue(config.isEnabledXsrfProtection()); assertThat(config.getLoginAttemptLimitTimeout()).isEqualTo(40);
assertFalse(config.isEnabledUserConverter()); assertThat(config.isEnabledXsrfProtection()).isTrue();
assertEquals("username", config.getNamespaceStrategy()); assertThat(config.isEnabledUserConverter()).isFalse();
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl()); assertThat(config.getNamespaceStrategy()).isEqualTo("username");
assertEquals("hitchhiker.mail", config.getMailDomainName()); assertThat(config.getLoginInfoUrl()).isEqualTo("https://scm-manager.org/login-info");
assertTrue("emergencyContacts", config.getEmergencyContacts().containsAll(Arrays.asList(expectedUsers))); assertThat(config.getMailDomainName()).isEqualTo("hitchhiker.mail");
assertThat(config.getEmergencyContacts()).contains(expectedUsers);
} }
@Test @Test
public void shouldMapAnonymousAccessFieldToAnonymousMode() { void shouldMapAnonymousAccessFieldToAnonymousMode() {
ConfigDto dto = createDefaultDto(); ConfigDto dto = createDefaultDto();
ScmConfiguration config = mapper.map(dto); ScmConfiguration config = mapper.map(dto);
assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode()); assertThat(config.getAnonymousMode()).isSameAs(AnonymousMode.PROTOCOL_ONLY);
dto.setAnonymousMode(null); dto.setAnonymousMode(null);
dto.setAnonymousAccessEnabled(false); dto.setAnonymousAccessEnabled(false);
ScmConfiguration config2 = mapper.map(dto); ScmConfiguration config2 = mapper.map(dto);
assertEquals(AnonymousMode.OFF, config2.getAnonymousMode()); assertThat(config2.getAnonymousMode()).isSameAs(AnonymousMode.OFF);
} }
private ConfigDto createDefaultDto() { private ConfigDto createDefaultDto() {
@@ -112,6 +105,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
configDto.setProxyExcludes(Sets.newSet(expectedExcludes)); configDto.setProxyExcludes(Sets.newSet(expectedExcludes));
configDto.setSkipFailedAuthenticators(true); configDto.setSkipFailedAuthenticators(true);
configDto.setPluginUrl("https://plug.ins"); configDto.setPluginUrl("https://plug.ins");
configDto.setPluginAuthUrl("https://plug.ins/oidc");
configDto.setLoginAttemptLimitTimeout(40); configDto.setLoginAttemptLimitTimeout(40);
configDto.setEnabledXsrfProtection(true); configDto.setEnabledXsrfProtection(true);
configDto.setNamespaceStrategy("username"); configDto.setNamespaceStrategy("username");

View File

@@ -33,6 +33,7 @@ import org.junit.Test;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationFinisher; import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.plugin.PluginCenterAuthenticator;
import sonia.scm.search.SearchEngine; import sonia.scm.search.SearchEngine;
import java.net.URI; import java.net.URI;
@@ -51,7 +52,6 @@ public class IndexResourceTest {
private SCMContextProvider scmContextProvider; private SCMContextProvider scmContextProvider;
private IndexResource indexResource; private IndexResource indexResource;
@Before @Before
public void setUpObjectUnderTest() { public void setUpObjectUnderTest() {
this.configuration = new ScmConfiguration(); this.configuration = new ScmConfiguration();
@@ -63,10 +63,28 @@ public class IndexResourceTest {
ResourceLinksMock.createMock(URI.create("/")), ResourceLinksMock.createMock(URI.create("/")),
scmContextProvider, scmContextProvider,
configuration, configuration,
initializationFinisher, searchEngine); initializationFinisher,
searchEngine
);
this.indexResource = new IndexResource(generator); this.indexResource = new IndexResource(generator);
} }
@Test
@SubjectAware(username = "dent", password = "secret")
public void shouldRenderPluginCenterAuthLink() {
IndexDto index = indexResource.getIndex();
Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isPresent();
}
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldNotRenderPluginCenterLoginLinkIfPermissionsAreMissing() {
IndexDto index = indexResource.getIndex();
Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isNotPresent();
}
@Test @Test
public void shouldRenderLoginUrlsForUnauthenticatedRequest() { public void shouldRenderLoginUrlsForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex();

View File

@@ -89,7 +89,7 @@ class InstalledPluginResourceTest {
@BeforeEach @BeforeEach
void prepareEnvironment() { void prepareEnvironment() {
pluginRootResource = new PluginRootResource(installedPluginResourceProvider, null, null); pluginRootResource = new PluginRootResource(installedPluginResourceProvider, null, null, null);
when(installedPluginResourceProvider.get()).thenReturn(installedPluginResource); when(installedPluginResourceProvider.get()).thenReturn(installedPluginResource);
dispatcher.addSingletonResource(pluginRootResource); dispatcher.addSingletonResource(pluginRootResource);
} }

View File

@@ -90,7 +90,7 @@ class PendingPluginResourceTest {
@BeforeEach @BeforeEach
void prepareEnvironment() { void prepareEnvironment() {
dispatcher.registerException(ShiroException.class, Response.Status.UNAUTHORIZED); dispatcher.registerException(ShiroException.class, Response.Status.UNAUTHORIZED);
PluginRootResource pluginRootResource = new PluginRootResource(null, null, Providers.of(pendingPluginResource)); PluginRootResource pluginRootResource = new PluginRootResource(null, null, Providers.of(pendingPluginResource), null);
dispatcher.addSingletonResource(pluginRootResource); dispatcher.addSingletonResource(pluginRootResource);
} }

View File

@@ -0,0 +1,469 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.inject.util.Providers;
import lombok.Value;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.plugin.AuthenticationInfo;
import sonia.scm.plugin.FetchAccessTokenFailedException;
import sonia.scm.plugin.PluginCenterAuthenticator;
import sonia.scm.security.Impersonator;
import sonia.scm.security.SecureParameterSerializer;
import sonia.scm.security.XsrfExcludes;
import sonia.scm.user.DisplayUser;
import sonia.scm.user.UserDisplayManager;
import sonia.scm.user.UserTestData;
import sonia.scm.web.RestDispatcher;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Optional;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static sonia.scm.api.v2.resources.PluginCenterAuthResource.*;
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class PluginCenterAuthResourceTest {
private final RestDispatcher dispatcher = new RestDispatcher();
private final ScmConfiguration scmConfiguration = new ScmConfiguration();
@Mock
private PluginCenterAuthenticator authenticator;
@Mock
private XsrfExcludes excludes;
@Mock
private ChallengeGenerator challengeGenerator;
@Mock
private UserDisplayManager userDisplayManager;
@Mock
private SecureParameterSerializer parameterSerializer;
@Mock
private Impersonator impersonator;
@BeforeEach
void setUpDispatcher() {
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
pathInfoStore.set(rootPathInfo);
PluginCenterAuthResource resource = new PluginCenterAuthResource(
pathInfoStore, authenticator, userDisplayManager,
scmConfiguration, excludes, challengeGenerator,
parameterSerializer, impersonator
);
dispatcher.addSingletonResource(
new PluginRootResource(
null,
null,
null,
Providers.of(resource)
)
);
}
@Nested
class GetAuthenticationInfo {
@Test
void shouldReturnEmptyAuthenticationInfo() throws URISyntaxException, IOException {
JsonNode root = getJson("/v2/plugins/auth");
assertThat(root.has("principal")).isFalse();
assertThat(root.has("pluginCenterSubject")).isFalse();
assertThat(root.has("date")).isFalse();
assertThat(root.get("_links").get("self").get("href").asText()).isEqualTo("/v2/plugins/auth");
}
@Test
void shouldReturnTrueForIsDefault() throws URISyntaxException, IOException {
JsonNode root = getJson("/v2/plugins/auth");
assertThat(root.get("default").asBoolean()).isTrue();
}
@Test
void shouldReturnFalseIfTheAuthUrlIsNotDefault() throws URISyntaxException, IOException {
scmConfiguration.setPluginAuthUrl("https://plug.ins");
JsonNode root = getJson("/v2/plugins/auth");
assertThat(root.get("default").asBoolean()).isFalse();
}
@Test
@SubjectAware(value = "marvin", permissions = "plugin:write")
void shouldReturnLoginLinkIfPermitted() throws URISyntaxException, IOException {
JsonNode root = getJson("/v2/plugins/auth");
assertThat(root.get("_links").get("login").get("href").asText()).isEqualTo("/v2/plugins/auth/login");
}
@Test
void shouldReturnAuthenticationInfo() throws IOException, URISyntaxException {
JsonNode root = requestAuthInfo();
assertThat(root.get("principal").asText()).isEqualTo("Tricia McMillan");
assertThat(root.get("pluginCenterSubject").asText()).isEqualTo("tricia.mcmillan@hitchhiker.com");
assertThat(root.get("date").asText()).isNotEmpty();
assertThat(root.get("_links").get("self").get("href").asText()).isEqualTo("/v2/plugins/auth");
}
@Test
void shouldNotReturnLogoutLinkWithoutWritePermission() throws IOException, URISyntaxException {
JsonNode root = requestAuthInfo();
assertThat(root.get("_links").has("logout")).isFalse();
}
@Test
@SubjectAware(value = "marvin", permissions = "plugin:write")
void shouldReturnLogoutLinkIfPermitted() throws IOException, URISyntaxException {
JsonNode root = requestAuthInfo();
assertThat(root.get("_links").get("logout").get("href").asText()).isEqualTo("/v2/plugins/auth");
}
@Test
@SubjectAware(value = "marvin", permissions = "plugin:write")
void shouldNotReturnLogoutLinkIfPermitted() throws IOException, URISyntaxException {
JsonNode root = requestAuthInfo();
assertThat(root.get("_links").get("logout").get("href").asText()).isEqualTo("/v2/plugins/auth");
}
private JsonNode requestAuthInfo() throws IOException, URISyntaxException {
AuthenticationInfo info = new SimpleAuthenticationInfo(
"trillian", "tricia.mcmillan@hitchhiker.com", Instant.now()
);
when(authenticator.getAuthenticationInfo()).thenReturn(Optional.of(info));
DisplayUser user = DisplayUser.from(UserTestData.createTrillian());
when(userDisplayManager.get("trillian")).thenReturn(Optional.of(user));
return getJson("/v2/plugins/auth");
}
}
@Nested
class Logout {
@Test
void shouldLogout() throws URISyntaxException {
MockHttpResponse response = request(MockHttpRequest.delete("/v2/plugins/auth"));
verify(authenticator).logout();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NO_CONTENT);
}
}
@Nested
class AuthRequest {
@Test
void shouldReturnErrorRedirectWithoutSourceParameter() throws URISyntaxException {
MockHttpResponse response = get("/v2/plugins/auth/login");
assertError(response, ERROR_SOURCE_MISSING);
}
@Test
void shouldReturnErrorRedirectWithoutPluginAuthUrlParameter() throws URISyntaxException {
scmConfiguration.setPluginAuthUrl("");
MockHttpResponse response = get("/v2/plugins/auth/login?source=/admin/plugins");
assertError(response, ERROR_AUTHENTICATION_DISABLED);
}
@Test
void shouldReturnErrorRedirectIfAlreadyAuthenticated() throws URISyntaxException {
when(authenticator.isAuthenticated()).thenReturn(true);
MockHttpResponse response = get("/v2/plugins/auth/login?source=/admin/plugins");
assertError(response, ERROR_ALREADY_AUTHENTICATED);
}
@Test
@SubjectAware("trillian")
void shouldReturnRedirectToPluginAuthUrl() throws URISyntaxException, IOException {
when(challengeGenerator.create()).thenReturn("abcd");
when(parameterSerializer.serialize(any(AuthParameter.class))).thenReturn("def");
scmConfiguration.setPluginAuthUrl("https://plug.ins");
MockHttpResponse response = get("/v2/plugins/auth/login?source=/admin/plugins");
assertRedirect(response, "https://plug.ins?instance=%2Fv2%2Fplugins%2Fauth%2Fcallback?params%3Ddef");
}
@Test
@SubjectAware("trillian")
void shouldExcludeCallbackFromXsrf() throws URISyntaxException, IOException {
when(challengeGenerator.create()).thenReturn("1234");
when(parameterSerializer.serialize(any(AuthParameter.class))).thenReturn("def");
scmConfiguration.setPluginAuthUrl("https://plug.ins");
get("/v2/plugins/auth/login?source=/admin/plugins");
verify(excludes).add("/v2/plugins/auth/callback");
}
@Test
@SubjectAware("trillian")
void shouldSendAuthParameters() throws URISyntaxException, IOException {
when(challengeGenerator.create()).thenReturn("abc123def");
when(parameterSerializer.serialize(any(AuthParameter.class))).thenReturn("xyz");
get("/v2/plugins/auth/login?source=/admin/plugins");
ArgumentCaptor<AuthParameter> captor = ArgumentCaptor.forClass(AuthParameter.class);
verify(parameterSerializer).serialize(captor.capture());
AuthParameter parameter = captor.getValue();
assertThat(parameter.getChallenge()).isEqualTo("abc123def");
assertThat(parameter.getSource()).isEqualTo("/admin/plugins");
assertThat(parameter.getPrincipal()).isEqualTo("trillian");
}
}
@Nested
@SubjectAware("marvin")
class AbortAuthentication {
@BeforeEach
void setUp() throws IOException {
lenient().when(challengeGenerator.create()).thenReturn("xyz");
lenient().when(parameterSerializer.serialize(any(AuthParameter.class))).thenReturn("secureParams");
}
@Test
void shouldReturnErrorRedirectWithoutParams() throws URISyntaxException {
MockHttpResponse response = get("/v2/plugins/auth/callback");
assertError(response, ERROR_PARAMS_MISSING);
}
@Test
void shouldReturnErrorRedirectWithoutChallenge() throws URISyntaxException, IOException {
mockParams("marvin", null, "/");
MockHttpResponse response = get("/v2/plugins/auth/callback?params=secureParams");
assertError(response, ERROR_CHALLENGE_MISSING);
}
@Test
void shouldReturnErrorRedirectWithChallengeMismatch() throws URISyntaxException, IOException {
mockParams("marvin", "abc", "/repos");
get("/v2/plugins/auth/login?source=/repos");
MockHttpResponse response = get("/v2/plugins/auth/callback?params=secureParams");
assertError(response, ERROR_CHALLENGE_DOES_NOT_MATCH);
}
@Test
void shouldRedirectToRoot() throws URISyntaxException, IOException {
mockParams("marvin", "xyz", null);
get("/v2/plugins/auth/login?source=/repos");
MockHttpResponse response = get("/v2/plugins/auth/callback?params=secureParams");
assertRedirect(response, "/");
}
@Test
void shouldRedirectToSource() throws URISyntaxException, IOException {
mockParams("marvin", "xyz", "/repos");
get("/v2/plugins/auth/login?source=/repos");
MockHttpResponse response = get("/v2/plugins/auth/callback?params=secureParams");
assertRedirect(response, "/repos");
}
@Test
void shouldRemoveCallbackFromXsrf() throws URISyntaxException, IOException {
mockParams("marvin", "xyz", "/repos");
get("/v2/plugins/auth/login?source=/repos");
get("/v2/plugins/auth/callback?params=secureParams");
verify(excludes).remove("/v2/plugins/auth/callback");
}
}
@Nested
@SubjectAware("slarti")
class AuthenticationCallback {
@BeforeEach
void setUp() throws IOException {
lenient().when(challengeGenerator.create()).thenReturn("abc");
lenient().when(parameterSerializer.serialize(any(AuthParameter.class))).thenReturn("secureParams");
}
@Test
void shouldReturnErrorRedirectWithoutParameters() throws URISyntaxException {
MockHttpResponse response = post("/v2/plugins/auth/callback", "trillian", "rf");
assertError(response, ERROR_PARAMS_MISSING);
}
@Test
void shouldReturnErrorRedirectWithoutChallengeParameter() throws URISyntaxException, IOException {
mockParams("slarti", null, "/");
MockHttpResponse response = post("/v2/plugins/auth/callback?params=secureParams", "slarti", "rf");
assertError(response, ERROR_CHALLENGE_MISSING);
}
@Test
void shouldReturnErrorRedirectWithChallengeMismatch() throws URISyntaxException, IOException {
mockParams("slarti", "xyz", "/");
get("/v2/plugins/auth/login?source=/repos");
MockHttpResponse response = post("/v2/plugins/auth/callback?params=secureParams", "trillian", "rf");
assertError(response, ERROR_CHALLENGE_DOES_NOT_MATCH);
}
@Test
void shouldReturnErrorRedirectFromFailedAuthentication() throws URISyntaxException, IOException {
mockParams("slarti", "abc", "/");
FetchAccessTokenFailedException exception = new FetchAccessTokenFailedException("failed ...");
doThrow(exception).when(authenticator).authenticate("slarti", "rf");
get("/v2/plugins/auth/login?source=/repos");
MockHttpResponse response = post("/v2/plugins/auth/callback?params=secureParams", "slarti", "rf");
assertError(response, exception.getCode());
}
@Test
void shouldAuthenticate() throws URISyntaxException, IOException {
mockParams("slarti", "abc", "/");
get("/v2/plugins/auth/login?source=/repos");
post("/v2/plugins/auth/callback?params=secureParams", "slarti", "refresh_token");
verify(authenticator).authenticate("slarti", "refresh_token");
}
@Test
void shouldRedirectToSource() throws URISyntaxException, IOException {
mockParams("slarti", "abc", "/users");
get("/v2/plugins/auth/login?source=/users");
MockHttpResponse response = post("/v2/plugins/auth/callback?params=secureParams", "slarti", "rrrrf");
assertRedirect(response, "/users");
}
@Test
void shouldRemoveCallbackFromXsrf() throws URISyntaxException, IOException {
mockParams("slarti", "abc", "/users");
get("/v2/plugins/auth/login?source=/repos");
post("/v2/plugins/auth/callback?params=secureParams", "slarti", "rf");
verify(excludes).remove("/v2/plugins/auth/callback");
}
}
private void mockParams(String principal, String challenge, String source) throws IOException {
AuthParameter params = new AuthParameter(principal, challenge, source);
when(parameterSerializer.deserialize("secureParams", AuthParameter.class)).thenReturn(params);
}
@CanIgnoreReturnValue
private MockHttpResponse post(String uri, String subject, String refreshToken) throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.post(uri);
request.addFormHeader("subject", subject);
request.addFormHeader("refresh_token", refreshToken);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
return response;
}
@CanIgnoreReturnValue
private MockHttpResponse get(String uri) throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get(uri);
return request(request);
}
private final ObjectMapper mapper = new ObjectMapper();
private JsonNode getJson(String uri) throws URISyntaxException, IOException {
MockHttpResponse response = get(uri);
return mapper.readTree(response.getContentAsString());
}
private MockHttpResponse request(MockHttpRequest request) {
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
return response;
}
private void assertError(MockHttpResponse response, String code) {
assertRedirect(response, "/error/" + code);
}
private void assertRedirect(MockHttpResponse response, String location) {
assertRedirect(response, (locationHeader) -> assertThat(locationHeader).isEqualTo(location));
}
private void assertRedirect(MockHttpResponse response, Consumer<String> location) {
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_SEE_OTHER);
location.accept(response.getOutputHeaders().getFirst("Location").toString());
}
@Value
private static class SimpleAuthenticationInfo implements AuthenticationInfo {
String principal;
String pluginCenterSubject;
Instant date;
}
private static final ScmPathInfo rootPathInfo = new ScmPathInfo() {
@Override
public URI getApiRestUri() {
return URI.create("/api");
}
@Override
public URI getRootUri() {
return URI.create("/");
}
};
}

View File

@@ -165,6 +165,16 @@ class PluginDtoMapperTest {
.isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/install?restart=true"); .isEqualTo("https://hitchhiker.com/v2/plugins/available/scm-cas-plugin/install?restart=true");
} }
@Test
void shouldNotAppendInstallLinkWithEmptyDownloadUrl() {
when(subject.isPermitted("plugin:write")).thenReturn(true);
AvailablePlugin plugin = createAvailable(createPluginInformation(), "");
PluginDto dto = mapper.mapAvailable(plugin);
assertThat(dto.getLinks().hasLink("install")).isFalse();
assertThat(dto.getLinks().hasLink("installWithRestart")).isFalse();
}
@Test @Test
void shouldReturnMiscellaneousIfCategoryIsNull() { void shouldReturnMiscellaneousIfCategoryIsNull() {
PluginInformation information = createPluginInformation(); PluginInformation information = createPluginInformation();

View File

@@ -28,26 +28,28 @@ import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState; import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.ThreadContext; import org.apache.shiro.util.ThreadContext;
import org.apache.shiro.util.ThreadState; import org.apache.shiro.util.ThreadState;
import org.junit.After; import org.junit.jupiter.api.AfterEach;
import org.junit.Before; import org.junit.jupiter.api.BeforeEach;
import org.junit.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.internal.util.collections.Sets; import org.mockito.internal.util.collections.Sets;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode; import sonia.scm.security.AnonymousMode;
import java.net.URI; import java.net.URI;
import java.util.Arrays; import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
public class ScmConfigurationToConfigDtoMapperTest { @ExtendWith(MockitoExtension.class)
class ScmConfigurationToConfigDtoMapperTest {
private final URI baseUri = URI.create("http://example.com/base/"); private final URI baseUri = URI.create("http://example.com/base/");
@@ -65,78 +67,84 @@ public class ScmConfigurationToConfigDtoMapperTest {
private URI expectedBaseUri; private URI expectedBaseUri;
@Before @BeforeEach
public void init() { void init() {
initMocks(this);
expectedBaseUri = baseUri.resolve(ConfigResource.CONFIG_PATH_V2); expectedBaseUri = baseUri.resolve(ConfigResource.CONFIG_PATH_V2);
subjectThreadState.bind(); subjectThreadState.bind();
ThreadContext.bind(subject); ThreadContext.bind(subject);
} }
@After @AfterEach
public void unbindSubject() { public void unbindSubject() {
ThreadContext.unbindSubject(); ThreadContext.unbindSubject();
} }
@Test @Test
public void shouldMapFields() { void shouldMapFields() {
ScmConfiguration config = createConfiguration(); ScmConfiguration config = createConfiguration();
when(subject.isPermitted("configuration:write:global")).thenReturn(true); when(subject.isPermitted("configuration:write:global")).thenReturn(true);
ConfigDto dto = mapper.map(config); ConfigDto dto = mapper.map(config);
assertEquals("heartOfGold", dto.getProxyPassword()); assertThat(dto.getProxyPassword()).isEqualTo("heartOfGold");
assertEquals(1234, dto.getProxyPort()); assertThat(dto.getProxyPort()).isEqualTo(1234);
assertEquals("proxyserver", dto.getProxyServer()); assertThat(dto.getProxyServer()).isEqualTo("proxyserver");
assertEquals("trillian", dto.getProxyUser()); assertThat(dto.getProxyUser()).isEqualTo("trillian");
assertTrue(dto.isEnableProxy()); assertThat(dto.isEnableProxy()).isTrue();
assertEquals("description", dto.getRealmDescription()); assertThat(dto.getRealmDescription()).isEqualTo("description");
assertTrue(dto.isDisableGroupingGrid()); assertThat(dto.isDisableGroupingGrid()).isTrue();
assertEquals("dd", dto.getDateFormat()); assertThat(dto.getDateFormat()).isEqualTo("dd");
assertSame(AnonymousMode.FULL, dto.getAnonymousMode()); assertThat(dto.getAnonymousMode()).isSameAs(AnonymousMode.FULL);
assertEquals("baseurl", dto.getBaseUrl()); assertThat(dto.getBaseUrl()).isEqualTo("baseurl");
assertTrue(dto.isForceBaseUrl()); assertThat(dto.isForceBaseUrl()).isTrue();
assertEquals(1, dto.getLoginAttemptLimit()); assertThat(dto.getLoginAttemptLimit()).isOne();
assertTrue("proxyExcludes", dto.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes))); assertThat(dto.getProxyExcludes()).contains(expectedExcludes);
assertTrue(dto.isSkipFailedAuthenticators()); assertThat(dto.isSkipFailedAuthenticators()).isTrue();
assertEquals("pluginurl", dto.getPluginUrl()); assertThat(dto.getPluginUrl()).isEqualTo("https://plug.ins");
assertEquals(2, dto.getLoginAttemptLimitTimeout()); assertThat(dto.getPluginAuthUrl()).isEqualTo("https://plug.ins/oidc");
assertTrue(dto.isEnabledXsrfProtection()); assertThat(dto.getLoginAttemptLimitTimeout()).isEqualTo(2);
assertEquals("username", dto.getNamespaceStrategy()); assertThat(dto.isEnabledXsrfProtection()).isTrue();
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl()); assertThat(dto.getNamespaceStrategy()).isEqualTo("username");
assertEquals("https://www.scm-manager.org/download/rss.xml", dto.getReleaseFeedUrl()); assertThat(dto.getLoginInfoUrl()).isEqualTo("https://scm-manager.org/login-info");
assertEquals("scm-manager.local", dto.getMailDomainName()); assertThat(dto.getReleaseFeedUrl()).isEqualTo("https://www.scm-manager.org/download/rss.xml");
assertTrue("emergencyContacts", dto.getEmergencyContacts().containsAll(Arrays.asList(expectedUsers))); assertThat(dto.getMailDomainName()).isEqualTo("scm-manager.local");
assertThat(dto.getEmergencyContacts()).contains(expectedUsers);
assertLinks(dto);
}
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref()); private void assertLinks(ConfigDto dto) {
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref()); assertThat(dto.getLinks().getLinkBy("self"))
.hasValueSatisfying(link -> assertThat(link.getHref()).isEqualTo(expectedBaseUri.toString()));
assertThat(dto.getLinks().getLinkBy("update"))
.hasValueSatisfying(link -> assertThat(link.getHref()).isEqualTo(expectedBaseUri.toString()));
} }
@Test @Test
public void shouldMapFieldsWithoutUpdate() { void shouldMapFieldsWithoutUpdate() {
ScmConfiguration config = createConfiguration(); ScmConfiguration config = createConfiguration();
when(subject.hasRole("configuration:write:global")).thenReturn(false); when(subject.hasRole("configuration:write:global")).thenReturn(false);
ConfigDto dto = mapper.map(config); ConfigDto dto = mapper.map(config);
assertEquals("baseurl", dto.getBaseUrl()); assertThat(dto.getBaseUrl()).isEqualTo("baseurl");
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref()); assertThat(dto.getLinks().getLinkBy("self"))
assertFalse(dto.getLinks().hasLink("update")); .hasValueSatisfying(link -> assertThat(link.getHref()).isEqualTo(expectedBaseUri.toString()));
assertThat(dto.getLinks().hasLink("update")).isFalse();
} }
@Test @Test
public void shouldMapAnonymousAccessField() { void shouldMapAnonymousAccessField() {
ScmConfiguration config = createConfiguration(); ScmConfiguration config = createConfiguration();
when(subject.hasRole("configuration:write:global")).thenReturn(false); when(subject.hasRole("configuration:write:global")).thenReturn(false);
ConfigDto dto = mapper.map(config); ConfigDto dto = mapper.map(config);
assertTrue(dto.isAnonymousAccessEnabled()); assertThat(dto.isAnonymousAccessEnabled()).isTrue();
config.setAnonymousMode(AnonymousMode.OFF); config.setAnonymousMode(AnonymousMode.OFF);
ConfigDto secondDto = mapper.map(config); ConfigDto secondDto = mapper.map(config);
assertFalse(secondDto.isAnonymousAccessEnabled()); assertThat(secondDto.isAnonymousAccessEnabled()).isFalse();
} }
private ScmConfiguration createConfiguration() { private ScmConfiguration createConfiguration() {
@@ -155,7 +163,8 @@ public class ScmConfigurationToConfigDtoMapperTest {
config.setLoginAttemptLimit(1); config.setLoginAttemptLimit(1);
config.setProxyExcludes(Sets.newSet(expectedExcludes)); config.setProxyExcludes(Sets.newSet(expectedExcludes));
config.setSkipFailedAuthenticators(true); config.setSkipFailedAuthenticators(true);
config.setPluginUrl("pluginurl"); config.setPluginUrl("https://plug.ins");
config.setPluginAuthUrl("https://plug.ins/oidc");
config.setLoginAttemptLimitTimeout(2); config.setLoginAttemptLimitTimeout(2);
config.setEnabledXsrfProtection(true); config.setEnabledXsrfProtection(true);
config.setNamespaceStrategy("username"); config.setNamespaceStrategy("username");

View File

@@ -0,0 +1,302 @@
/*
* 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.plugin;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import org.apache.shiro.authz.AuthorizationException;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.event.ScmEventBus;
import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.net.ahc.AdvancedHttpRequestWithBody;
import sonia.scm.net.ahc.AdvancedHttpResponse;
import sonia.scm.plugin.PluginCenterAuthenticator.RefreshResponse;
import sonia.scm.store.InMemoryConfigurationStoreFactory;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
import static sonia.scm.plugin.PluginCenterAuthenticator.*;
import static sonia.scm.plugin.PluginCenterAuthenticator.RefreshRequest;
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class PluginCenterAuthenticatorTest {
private PluginCenterAuthenticator authenticator;
@Mock
private AdvancedHttpClient advancedHttpClient;
@Mock(answer = Answers.RETURNS_SELF)
private AdvancedHttpRequestWithBody request;
private ScmConfiguration scmConfiguration;
@Mock
private ScmEventBus eventBus;
private final InMemoryConfigurationStoreFactory factory = InMemoryConfigurationStoreFactory.create();
@BeforeEach
void setUpObjectUnderTest() {
scmConfiguration = new ScmConfiguration();
authenticator = new PluginCenterAuthenticator(factory, scmConfiguration, advancedHttpClient, eventBus);
}
@Test
@SubjectAware("marvin")
void shouldFailAuthenticationWithoutPermissions() {
assertThrows(AuthorizationException.class, () -> authenticator.authenticate("marvin@hitchhiker.com", "refresh-token"));
}
@Test
@SubjectAware(value = "marvin", permissions = "plugin:read")
void shouldFailAuthenticationWithReadPermissions() {
assertThrows(AuthorizationException.class, () -> authenticator.authenticate("marvin@hitchhiker.com", "refresh-token"));
}
@Test
@SubjectAware("marvin")
void shouldFailToFetchAccessTokenWithoutPermission() {
assertThrows(AuthorizationException.class, () -> authenticator.fetchAccessToken());
}
@Test
@SubjectAware("marvin")
void shouldFailGetAuthenticationInfoWithoutPermission() {
assertThrows(AuthorizationException.class, () -> authenticator.getAuthenticationInfo());
}
@Test
@SubjectAware("marvin")
void shouldFailLogoutWithoutPermission() {
assertThrows(AuthorizationException.class, () -> authenticator.logout());
}
@Nested
@SubjectAware(value = "trillian", permissions = {"plugin:read", "plugin:write"})
class WithPermissions {
@Test
void shouldReturnFalseWithoutRefreshToken() {
assertThat(authenticator.isAuthenticated()).isFalse();
}
@Test
void shouldFailWithoutRefreshToken() {
assertThrows(IllegalArgumentException.class, () -> authenticator.authenticate("tricia.mcmillan@hitchhiker.com", null));
}
@Test
void shouldFailWithEmptyRefreshToken() {
assertThrows(IllegalArgumentException.class, () -> authenticator.authenticate("tricia.mcmillan@hitchhiker.com", ""));
}
@Test
void shouldFailWithoutSubject() {
assertThrows(IllegalArgumentException.class, () -> authenticator.authenticate(null, "rf"));
}
@Test
void shouldFailWithEmptySubject() {
assertThrows(IllegalArgumentException.class, () -> authenticator.authenticate("", "rf"));
}
@Test
void shouldFailWithoutPluginAuthUrl() {
scmConfiguration.setPluginAuthUrl(null);
assertThrows(IllegalStateException.class, () -> authenticator.authenticate("tricia.mcmillan@hitchhiker.com", "my-awesome-refresh-token"));
}
@Test
void shouldAuthenticate() throws IOException {
mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
authenticator.authenticate("tricia.mcmillan@hitchhiker.com", "my-awesome-refresh-token");
assertThat(authenticator.isAuthenticated()).isTrue();
}
@Test
void shouldFireLoginEvent() throws IOException {
mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
authenticator.authenticate("tricia.mcmillan@hitchhiker.com", "my-awesome-refresh-token");
ArgumentCaptor<PluginCenterLoginEvent> captor = ArgumentCaptor.forClass(PluginCenterLoginEvent.class);
verify(eventBus).post(captor.capture());
AuthenticationInfo info = captor.getValue().getAuthenticationInfo();
assertThat(info.getPluginCenterSubject()).isEqualTo("tricia.mcmillan@hitchhiker.com");
}
@Test
void shouldFailFetchWithoutPriorAuthentication() {
assertThrows(IllegalStateException.class, () -> authenticator.fetchAccessToken());
}
@Test
void shouldUseUrlFromScmConfiguration() throws IOException {
preAuth("cool-refresh-token");
scmConfiguration.setPluginAuthUrl("https://pca.org/oidc/");
mockAuthProtocol("https://pca.org/oidc/refresh", "access", "refresh");
String accessToken = authenticator.fetchAccessToken();
assertThat(accessToken).isEqualTo("access");
}
@Test
void shouldFailIfFetchFails() throws IOException {
preAuth("cool-refresh-token");
scmConfiguration.setPluginAuthUrl("https://plug.ins/oidc/");
when(advancedHttpClient.post("https://plug.ins/oidc/refresh")).thenReturn(request);
when(request.request()).thenThrow(new IOException("network down down down"));
assertThrows(FetchAccessTokenFailedException.class, () -> authenticator.fetchAccessToken());
}
@Test
void shouldFailIfFetchResponseIsNotSuccessful() throws IOException {
preAuth("cool-refresh-token");
scmConfiguration.setPluginAuthUrl("https://plug.ins/oidc/");
when(advancedHttpClient.post("https://plug.ins/oidc/refresh")).thenReturn(request);
AdvancedHttpResponse response = mock(AdvancedHttpResponse.class);
when(request.request()).thenReturn(response);
when(response.isSuccessful()).thenReturn(false);
assertThrows(FetchAccessTokenFailedException.class, () -> authenticator.fetchAccessToken());
}
@Test
void shouldFetchAccessToken() throws IOException {
preAuth("cool-refresh-token");
mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
String accessToken = authenticator.fetchAccessToken();
assertThat(accessToken).isEqualTo("access");
}
@Test
void shouldStoreRefreshTokenAfterFetch() throws IOException {
preAuth("refreshOne");
mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "accessTwo", "refreshTwo");
authenticator.fetchAccessToken();
authenticator.fetchAccessToken();
ArgumentCaptor<RefreshRequest> captor = ArgumentCaptor.forClass(RefreshRequest.class);
verify(request, times(2)).jsonContent(captor.capture());
List<String> refreshTokens = captor.getAllValues()
.stream()
.map(RefreshRequest::getRefreshToken)
.collect(Collectors.toList());
assertThat(refreshTokens).containsExactlyInAnyOrder("refreshOne", "refreshTwo");
}
@Test
void shouldReturnEmptyWithoutPriorAuthentication() {
assertThat(authenticator.getAuthenticationInfo()).isEmpty();
}
@Test
void shouldReturnAuthenticationInfo() {
preAuth("refresh_token");
assertThat(authenticator.getAuthenticationInfo()).hasValueSatisfying(info -> {
assertThat(info.getPluginCenterSubject()).isEqualTo("tricia.mcmillan@hitchhiker.com");
assertThat(info.getPrincipal()).isEqualTo("trillian");
assertThat(info.getDate()).isNotNull();
});
}
@Test
void shouldLogout() {
preAuth("refresh_token");
authenticator.logout();
assertThat(authenticator.isAuthenticated()).isFalse();
assertThat(authenticator.getAuthenticationInfo()).isEmpty();
}
@Test
void shouldFireLogoutEventAfterLogout() {
preAuth("refresh_token");
authenticator.logout();
ArgumentCaptor<PluginCenterLogoutEvent> captor = ArgumentCaptor.forClass(PluginCenterLogoutEvent.class);
verify(eventBus).post(captor.capture());
AuthenticationInfo info = captor.getValue().getPriorAuthenticationInfo();
assertThat(info.getPluginCenterSubject()).isEqualTo("tricia.mcmillan@hitchhiker.com");
}
@SuppressWarnings("unchecked")
private void preAuth(String refreshToken) {
Authentication authentication = new Authentication();
authentication.setPluginCenterSubject("tricia.mcmillan@hitchhiker.com");
authentication.setPrincipal("trillian");
authentication.setRefreshToken(refreshToken);
authentication.setDate(Instant.now());
factory.get(STORE_NAME, null).set(authentication);
}
@CanIgnoreReturnValue
private void mockAuthProtocol(String url, String accessToken, String refreshToken) throws IOException {
when(advancedHttpClient.post(url)).thenReturn(request);
AdvancedHttpResponse response = mock(AdvancedHttpResponse.class);
when(request.request()).thenReturn(response);
RefreshResponse refreshResponse = new RefreshResponse();
refreshResponse.setAccessToken(accessToken);
refreshResponse.setRefreshToken(refreshToken);
when(response.contentFromJson(RefreshResponse.class)).thenReturn(refreshResponse);
when(response.isSuccessful()).thenReturn(true);
}
}
}

View File

@@ -32,6 +32,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
import sonia.scm.net.ahc.AdvancedHttpClient; import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.net.ahc.AdvancedHttpRequest;
import sonia.scm.net.ahc.AdvancedHttpResponse; import sonia.scm.net.ahc.AdvancedHttpResponse;
import java.io.IOException; import java.io.IOException;
@@ -40,8 +41,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.*;
import static org.mockito.Mockito.when;
import static sonia.scm.plugin.Tracing.SPAN_KIND; import static sonia.scm.plugin.Tracing.SPAN_KIND;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -49,7 +49,7 @@ class PluginCenterLoaderTest {
private static final String PLUGIN_URL = "https://plugins.hitchhiker.com"; private static final String PLUGIN_URL = "https://plugins.hitchhiker.com";
@Mock(answer = Answers.RETURNS_DEEP_STUBS) @Mock
private AdvancedHttpClient client; private AdvancedHttpClient client;
@Mock @Mock
@@ -58,9 +58,15 @@ class PluginCenterLoaderTest {
@Mock @Mock
private ScmEventBus eventBus; private ScmEventBus eventBus;
@Mock
private PluginCenterAuthenticator authenticator;
@InjectMocks @InjectMocks
private PluginCenterLoader loader; private PluginCenterLoader loader;
@Mock(answer = Answers.RETURNS_SELF)
private AdvancedHttpRequest request;
@Test @Test
void shouldFetch() throws IOException { void shouldFetch() throws IOException {
Set<AvailablePlugin> plugins = Collections.emptySet(); Set<AvailablePlugin> plugins = Collections.emptySet();
@@ -73,12 +79,16 @@ class PluginCenterLoaderTest {
} }
private AdvancedHttpResponse request() throws IOException { private AdvancedHttpResponse request() throws IOException {
return client.get(PLUGIN_URL).spanKind(SPAN_KIND).request(); when(client.get(PLUGIN_URL)).thenReturn(request);
AdvancedHttpResponse response = mock(AdvancedHttpResponse.class);
when(request.request()).thenReturn(response);
return response;
} }
@Test @Test
void shouldReturnEmptySetIfPluginCenterNotBeReached() throws IOException { void shouldReturnEmptySetIfPluginCenterNotBeReached() throws IOException {
when(request()).thenThrow(new IOException("failed to fetch")); when(client.get(PLUGIN_URL)).thenReturn(request);
when(request.request()).thenThrow(new IOException("failed to fetch"));
Set<AvailablePlugin> fetch = loader.load(PLUGIN_URL); Set<AvailablePlugin> fetch = loader.load(PLUGIN_URL);
assertThat(fetch).isEmpty(); assertThat(fetch).isEmpty();
@@ -86,10 +96,31 @@ class PluginCenterLoaderTest {
@Test @Test
void shouldFirePluginCenterErrorEvent() throws IOException { void shouldFirePluginCenterErrorEvent() throws IOException {
when(request()).thenThrow(new IOException("failed to fetch")); when(client.get(PLUGIN_URL)).thenReturn(request);
when(request.request()).thenThrow(new IOException("failed to fetch"));
loader.load(PLUGIN_URL); loader.load(PLUGIN_URL);
verify(eventBus).post(any(PluginCenterErrorEvent.class)); verify(eventBus).post(any(PluginCenterErrorEvent.class));
} }
@Test
void shouldAppendAccessToken() throws IOException {
when(authenticator.isAuthenticated()).thenReturn(true);
when(authenticator.fetchAccessToken()).thenReturn("mega-cool-at");
mockResponse();
loader.load(PLUGIN_URL);
verify(request).bearerAuth("mega-cool-at");
}
private Set<AvailablePlugin> mockResponse() throws IOException {
PluginCenterDto dto = new PluginCenterDto();
Set<AvailablePlugin> plugins = Collections.emptySet();
when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto);
when(mapper.map(dto)).thenReturn(plugins);
return plugins;
}
} }

View File

@@ -45,6 +45,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -86,6 +87,7 @@ class PluginCenterTest {
} }
@Test @Test
@SuppressWarnings("unchecked")
void shouldCache() { void shouldCache() {
Set<AvailablePlugin> first = new HashSet<>(); Set<AvailablePlugin> first = new HashSet<>();
when(loader.load(anyString())).thenReturn(first, new HashSet<>()); when(loader.load(anyString())).thenReturn(first, new HashSet<>());
@@ -94,4 +96,25 @@ class PluginCenterTest {
assertThat(pluginCenter.getAvailable()).isSameAs(first); assertThat(pluginCenter.getAvailable()).isSameAs(first);
} }
@Test
@SuppressWarnings("unchecked")
void shouldClearCache() {
Set<AvailablePlugin> first = new HashSet<>();
when(loader.load(anyString())).thenReturn(first, new HashSet<>());
assertThat(pluginCenter.getAvailable()).isSameAs(first);
pluginCenter.handle(new PluginCenterLoginEvent(null));
assertThat(pluginCenter.getAvailable()).isNotSameAs(first);
}
@Test
void shouldLoadOnRefresh() {
Set<AvailablePlugin> plugins = new HashSet<>();
when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(plugins);
pluginCenter.refresh();
verify(loader).load(PLUGIN_URL_BASE + "2.0.0");
}
} }

View File

@@ -34,6 +34,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.net.ahc.AdvancedHttpClient; import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.net.ahc.AdvancedHttpRequest;
import sonia.scm.net.ahc.AdvancedHttpResponse; import sonia.scm.net.ahc.AdvancedHttpResponse;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -46,28 +47,30 @@ import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.*;
import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static sonia.scm.plugin.Tracing.SPAN_KIND; import static sonia.scm.plugin.Tracing.SPAN_KIND;
@ExtendWith({MockitoExtension.class}) @ExtendWith(MockitoExtension.class)
class PluginInstallerTest { class PluginInstallerTest {
@Mock @Mock
private SCMContextProvider context; private SCMContextProvider context;
@Mock(answer = Answers.RETURNS_DEEP_STUBS) @Mock
private AdvancedHttpClient client; private AdvancedHttpClient client;
@Mock @Mock
private SmpDescriptorExtractor extractor; private SmpDescriptorExtractor extractor;
@Mock
private PluginCenterAuthenticator authenticator;
@InjectMocks @InjectMocks
private PluginInstaller installer; private PluginInstaller installer;
@Mock(answer = Answers.RETURNS_SELF)
private AdvancedHttpRequest request;
private Path directory; private Path directory;
@BeforeEach @BeforeEach
@@ -108,7 +111,10 @@ class PluginInstallerTest {
} }
private AdvancedHttpResponse request(String url) throws IOException { private AdvancedHttpResponse request(String url) throws IOException {
return client.get(url).spanKind(SPAN_KIND).request(); AdvancedHttpResponse response = mock(AdvancedHttpResponse.class);
when(client.get(url)).thenReturn(request);
when(request.request()).thenReturn(response);
return response;
} }
private AvailablePlugin createGitPlugin() { private AvailablePlugin createGitPlugin() {
@@ -121,7 +127,8 @@ class PluginInstallerTest {
@Test @Test
void shouldThrowPluginDownloadException() throws IOException { void shouldThrowPluginDownloadException() throws IOException {
when(request("https://download.hitchhiker.com")).thenThrow(new IOException("failed to download")); when(client.get("https://download.hitchhiker.com")).thenReturn(request);
when(request.request()).thenThrow(new IOException("failed to download"));
PluginInstallationContext context = PluginInstallationContext.empty(); PluginInstallationContext context = PluginInstallationContext.empty();
AvailablePlugin gitPlugin = createGitPlugin(); AvailablePlugin gitPlugin = createGitPlugin();
@@ -190,6 +197,17 @@ class PluginInstallerTest {
assertThat(exception.getDownloaded().getVersion()).isEqualTo("1.1.0"); assertThat(exception.getDownloaded().getVersion()).isEqualTo("1.1.0");
} }
@Test
void shouldAppendBearerAuth() throws IOException {
when(authenticator.isAuthenticated()).thenReturn(true);
when(authenticator.fetchAccessToken()).thenReturn("atat");
mockContent("42");
installer.install(PluginInstallationContext.empty(), createGitPlugin());
verify(request).bearerAuth("atat");
}
private AvailablePlugin createPlugin(String name, String url, String checksum) { private AvailablePlugin createPlugin(String name, String url, String checksum) {
PluginInformation information = new PluginInformation(); PluginInformation information = new PluginInformation();
information.setName(name); information.setName(name);

View File

@@ -28,9 +28,7 @@ import org.mockito.Answers;
import java.util.Optional; import java.util.Optional;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class PluginTestHelper { public class PluginTestHelper {
public static AvailablePlugin createAvailable(String name) { public static AvailablePlugin createAvailable(String name) {
@@ -62,9 +60,14 @@ public class PluginTestHelper {
} }
public static AvailablePlugin createAvailable(PluginInformation information) { public static AvailablePlugin createAvailable(PluginInformation information) {
return createAvailable(information, "https://scm-manager.org/download");
}
public static AvailablePlugin createAvailable(PluginInformation information, String url) {
AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class); AvailablePluginDescriptor descriptor = mock(AvailablePluginDescriptor.class);
lenient().when(descriptor.getInformation()).thenReturn(information); lenient().when(descriptor.getInformation()).thenReturn(information);
lenient().when(descriptor.getInstallLink()).thenReturn(Optional.of("mycloudogu.com/install/my_plugin")); lenient().when(descriptor.getInstallLink()).thenReturn(Optional.of("mycloudogu.com/install/my_plugin"));
lenient().when(descriptor.getUrl()).thenReturn(url);
return new AvailablePlugin(descriptor); return new AvailablePlugin(descriptor);
} }

View File

@@ -0,0 +1,65 @@
/*
* 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.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
class SecureParameterSerializerTest {
private final SecureParameterSerializer serializer = new SecureParameterSerializer(new ObjectMapper());
@Test
void shouldSerializeAndDeserialize() throws IOException {
TestObject object = new TestObject("1", 2);
String serialized = serializer.serialize(object);
assertThat(serialized).isNotEmpty();
object = serializer.deserialize(serialized, TestObject.class);
assertThat(object).isNotNull();
assertThat(object.getOne()).isEqualTo("1");
assertThat(object.getTwo()).isEqualTo(2);
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class TestObject {
private String one;
private int two;
}
}

View File

@@ -30,17 +30,14 @@ import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import java.util.Optional; import java.util.Optional;
import static org.junit.Assert.assertFalse; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -58,6 +55,8 @@ class XsrfAccessTokenValidatorTest {
@Mock @Mock
private AccessToken accessToken; private AccessToken accessToken;
private final XsrfExcludes excludes = new XsrfExcludes();
private XsrfAccessTokenValidator validator; private XsrfAccessTokenValidator validator;
/** /**
@@ -65,7 +64,7 @@ class XsrfAccessTokenValidatorTest {
*/ */
@BeforeEach @BeforeEach
void prepareObjectUnderTest() { void prepareObjectUnderTest() {
validator = new XsrfAccessTokenValidator(() -> request); validator = new XsrfAccessTokenValidator(() -> request, excludes);
} }
@Nested @Nested
@@ -86,7 +85,7 @@ class XsrfAccessTokenValidatorTest {
when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("abc"); when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("abc");
// execute and assert // execute and assert
assertTrue(validator.validate(accessToken)); assertThat(validator.validate(accessToken)).isTrue();
} }
/** /**
@@ -99,7 +98,7 @@ class XsrfAccessTokenValidatorTest {
when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("123"); when(request.getHeader(Xsrf.HEADER_KEY)).thenReturn("123");
// execute and assert // execute and assert
assertFalse(validator.validate(accessToken)); assertThat(validator.validate(accessToken)).isFalse();
} }
/** /**
@@ -111,7 +110,7 @@ class XsrfAccessTokenValidatorTest {
when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc"));
// execute and assert // execute and assert
assertFalse(validator.validate(accessToken)); assertThat(validator.validate(accessToken)).isFalse();
} }
/** /**
@@ -123,30 +122,43 @@ class XsrfAccessTokenValidatorTest {
when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.empty()); when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.empty());
// execute and assert // execute and assert
assertTrue(validator.validate(accessToken)); assertThat(validator.validate(accessToken)).isTrue();
} }
@Test
void shouldNotValidateExcludedRequest() {
excludes.add("/excluded");
// prepare
when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc"));
when(request.getRequestURI()).thenReturn("/excluded");
// execute and assert
assertThat(validator.validate(accessToken)).isTrue();
}
} }
@ParameterizedTest @ParameterizedTest
@CsvSource({"GET", "HEAD", "OPTIONS"}) @ValueSource(strings = {"GET", "HEAD", "OPTIONS"})
void shouldNotValidateReadRequests(String method) { void shouldNotValidateReadRequests(String method) {
// prepare // prepare
when(request.getMethod()).thenReturn(method); when(request.getMethod()).thenReturn(method);
when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc"));
// execute and assert // execute and assert
assertTrue(validator.validate(accessToken)); assertThat(validator.validate(accessToken)).isTrue();
} }
@ParameterizedTest @ParameterizedTest
@CsvSource({"POST", "PUT", "DELETE", "PATCH"}) @ValueSource(strings = {"GET", "HEAD", "OPTIONS"})
void shouldFailValidationOfWriteRequests(String method) { void shouldFailValidationOfWriteRequests(String method) {
// prepare // prepare
when(request.getMethod()).thenReturn(method); when(request.getMethod()).thenReturn(method);
when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc")); when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(Optional.of("abc"));
// execute and assert // execute and assert
assertFalse(validator.validate(accessToken)); assertThat(validator.validate(accessToken)).isTrue();
} }
} }