diff --git a/gradle/changelog/pc_auth_failure.yml b/gradle/changelog/pc_auth_failure.yml
new file mode 100644
index 0000000000..65c046c0dd
--- /dev/null
+++ b/gradle/changelog/pc_auth_failure.yml
@@ -0,0 +1,2 @@
+- type: changed
+ description: Fetch plugins without authentication, if prior authentication failed ([#1940](https://github.com/scm-manager/scm-manager/pull/1940))
diff --git a/scm-ui/ui-api/src/usePluginCenterAuthInfo.ts b/scm-ui/ui-api/src/usePluginCenterAuthInfo.ts
index a160625a20..8f64d4ba5d 100644
--- a/scm-ui/ui-api/src/usePluginCenterAuthInfo.ts
+++ b/scm-ui/ui-api/src/usePluginCenterAuthInfo.ts
@@ -28,6 +28,16 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
import { apiClient } from "./apiclient";
import { useLocation } from "react-router-dom";
+const appendQueryParam = (link: Link, name: string, value: string) => {
+ let href = link.href;
+ if (href.includes("?")) {
+ href += "&";
+ } else {
+ href += "?";
+ }
+ link.href = href + name + "=" + value;
+};
+
export const usePluginCenterAuthInfo = (): ApiResult => {
const link = useIndexLink("pluginCenterAuth");
const location = useLocation();
@@ -42,7 +52,10 @@ export const usePluginCenterAuthInfo = (): ApiResult response.json())
.then((result: PluginCenterAuthenticationInfo) => {
if (result._links?.login) {
- (result._links.login as Link).href += `?source=${location.pathname}`;
+ appendQueryParam(result._links.login as Link, "source", location.pathname);
+ }
+ if (result._links?.reconnect) {
+ appendQueryParam(result._links.reconnect as Link, "source", location.pathname);
}
return result;
});
diff --git a/scm-ui/ui-components/src/SmallLoadingSpinner.tsx b/scm-ui/ui-components/src/SmallLoadingSpinner.tsx
index 85eae8ec72..1342330f1f 100644
--- a/scm-ui/ui-components/src/SmallLoadingSpinner.tsx
+++ b/scm-ui/ui-components/src/SmallLoadingSpinner.tsx
@@ -22,13 +22,16 @@
* SOFTWARE.
*/
import React, { FC } from "react";
+import classNames from "classnames";
-const SmallLoadingSpinner: FC = () => {
- return (
-
- );
+type Props = {
+ className?: string;
};
+const SmallLoadingSpinner: FC = ({ className }) => (
+
+);
+
export default SmallLoadingSpinner;
diff --git a/scm-ui/ui-styles/src/components/_main.scss b/scm-ui/ui-styles/src/components/_main.scss
index 1463512240..375264aecd 100644
--- a/scm-ui/ui-styles/src/components/_main.scss
+++ b/scm-ui/ui-styles/src/components/_main.scss
@@ -370,6 +370,18 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
border-color: $info !important;
}
+.has-border-danger {
+ border-color: $danger !important;
+}
+
+.has-border-warning {
+ border-color: $warning !important;
+}
+
+.has-border-primary {
+ border-color: $danger !important;
+}
+
ul.is-separated {
> li:after {
content: ",\2800";
diff --git a/scm-ui/ui-types/src/Plugin.ts b/scm-ui/ui-types/src/Plugin.ts
index c351aea7d8..f4b657c1a2 100644
--- a/scm-ui/ui-types/src/Plugin.ts
+++ b/scm-ui/ui-types/src/Plugin.ts
@@ -65,4 +65,5 @@ export type PluginCenterAuthenticationInfo = HalRepresentation & {
pluginCenterSubject?: string;
date?: string;
default: boolean;
+ failed: boolean;
};
diff --git a/scm-ui/ui-webapp/public/locales/de/admin.json b/scm-ui/ui-webapp/public/locales/de/admin.json
index ad413ccbc2..412a0b0318 100644
--- a/scm-ui/ui-webapp/public/locales/de/admin.json
+++ b/scm-ui/ui-webapp/public/locales/de/admin.json
@@ -88,6 +88,17 @@
},
"myCloudogu": {
"connectionInfo": "Instanz ist mit myCloudogu verbunden.\nAccount: {{pluginCenterSubject}}",
+ "error": {
+ "info": "myCloudogu Authentifizierungsinformationen konnten nicht abgerufen werden. Klicken Sie, um Details zu sehen.",
+ "title": "Fehler"
+ },
+ "failed": {
+ "info": "Verbindung zu myCloudogu mit Account {{pluginCenterSubject}} is fehlgeschlagen",
+ "message": "Die Verbindung der SCM-Manager Instanz mit <0>myCloudogu0> is fehlgeschlagen. Der Benutzer <1>{{subject}}1> konnte nicht authentifiziert werden.",
+ "button": {
+ "label": "Erneut mit <0>myCloudogu0> verbinden"
+ }
+ },
"login": {
"button": {
"label": "Mit <0>myCloudogu0> verbinden"
diff --git a/scm-ui/ui-webapp/public/locales/en/admin.json b/scm-ui/ui-webapp/public/locales/en/admin.json
index a44c6e6509..6c6606033b 100644
--- a/scm-ui/ui-webapp/public/locales/en/admin.json
+++ b/scm-ui/ui-webapp/public/locales/en/admin.json
@@ -88,6 +88,17 @@
},
"myCloudogu": {
"connectionInfo": "Instance is connected to myCloudogu.\nAccount: {{pluginCenterSubject}}",
+ "error": {
+ "info": "Failed to retrieve myCloudogu authentication information. Click for more details.",
+ "title": "Error"
+ },
+ "failed": {
+ "info": "Connection to myCloudogu failed for account {{pluginCenterSubject}}",
+ "message": "The connection of the SCM Manager instance with <0>myCloudogu0> failed. The user <1>{{subject}}1> could not be authenticated. Click Reconnect to restore the connection.",
+ "button": {
+ "label": "Reconnect to <0>myCloudogu0>"
+ }
+ },
"login": {
"button": {
"label": "Connect to <0>myCloudogu0>"
diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/MyCloudoguBanner.tsx b/scm-ui/ui-webapp/src/admin/plugins/components/MyCloudoguBanner.tsx
index f06720566b..2db75647e3 100644
--- a/scm-ui/ui-webapp/src/admin/plugins/components/MyCloudoguBanner.tsx
+++ b/scm-ui/ui-webapp/src/admin/plugins/components/MyCloudoguBanner.tsx
@@ -22,25 +22,84 @@
* SOFTWARE.
*/
-import { Button } from "@scm-manager/ui-components";
-import * as React from "react";
-import { FC } from "react";
-import styled from "styled-components";
+import React, { FC } from "react";
import { Trans, useTranslation } from "react-i18next";
-
-const MyCloudoguBannerWrapper = styled.div`
- border: 1px solid;
-`;
+import { Link, PluginCenterAuthenticationInfo } from "@scm-manager/ui-types";
+import classNames from "classnames";
+import styled from "styled-components";
+import { Button } from "@scm-manager/ui-components";
type Props = {
- loginLink?: string;
+ info: PluginCenterAuthenticationInfo;
};
-const MyCloudoguBanner: FC = ({ loginLink }) => {
+const MyCloudoguBanner: FC = ({ info }) => {
+ const loginLink = (info._links.login as Link)?.href;
+ if (loginLink) {
+ return ;
+ }
+
+ if (info.failed) {
+ const reconnectLink = (info._links.reconnect as Link)?.href;
+ if (reconnectLink) {
+ return ;
+ }
+ }
+
+ return null;
+};
+
+type PropsWithLink = Props & {
+ link: string;
+};
+
+const FailedAuthentication: FC = ({ info, link }) => {
const [t] = useTranslation("admin");
- return loginLink ? (
-
-
+ return (
+
+
+ myCloudogu, ]}
+ />
+
+
+ myCloudogu]}
+ />
+
+
+ );
+};
+
+type ContainerProps = {
+ className?: string;
+};
+
+const Container: FC = ({ className, children }) => (
+
+ {children}
+
+);
+
+const DivWithSolidBorder = styled.div`
+ border: 2px solid;
+`;
+
+const Unauthenticated: FC = ({ link, info }) => {
+ const [t] = useTranslation("admin");
+ return (
+
+
= ({ loginLink }) => {
]}
/>
-
- ) : null;
+
+ );
};
export default MyCloudoguBanner;
diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/PluginCenterAuthInfo.tsx b/scm-ui/ui-webapp/src/admin/plugins/components/PluginCenterAuthInfo.tsx
new file mode 100644
index 0000000000..e442d8081f
--- /dev/null
+++ b/scm-ui/ui-webapp/src/admin/plugins/components/PluginCenterAuthInfo.tsx
@@ -0,0 +1,131 @@
+/*
+ * 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, useState } from "react";
+import {
+ ErrorNotification,
+ Icon,
+ Modal,
+ NoStyleButton,
+ SmallLoadingSpinner,
+ Tooltip
+} from "@scm-manager/ui-components";
+import { PluginCenterAuthenticationInfo } from "@scm-manager/ui-types";
+import { useTranslation } from "react-i18next";
+
+type PluginCenterAuthInfoProps = {
+ data?: PluginCenterAuthenticationInfo;
+ isLoading: boolean;
+ error: Error | null;
+};
+
+const PluginCenterAuthInfo: FC = props => (
+
+
+
+);
+
+const Inner: FC = ({ data, isLoading, error }) => {
+ if (isLoading) {
+ return ;
+ }
+ if (error) {
+ return ;
+ }
+ if (!data || !data.pluginCenterSubject) {
+ return null;
+ }
+ if (data.failed) {
+ return ;
+ }
+
+ return ;
+};
+
+type ErrorProps = {
+ error: Error;
+};
+
+const AuthenticationError: FC = ({ error }) => {
+ const [t] = useTranslation("admin");
+ const [showModal, setShowModal] = useState(false);
+ return (
+ <>
+
+ setShowModal(true)}>
+
+
+
+ {showModal ? setShowModal(false)} /> : null}
+ >
+ );
+};
+
+type ErrorModalProps = {
+ error: Error;
+ onClose: () => void;
+};
+
+const ErrorModal: FC = ({ error, onClose }) => {
+ const [t] = useTranslation("admin");
+ return (
+
+
+
+ );
+};
+
+type InfoProps = {
+ info: PluginCenterAuthenticationInfo;
+};
+
+const AuthenticationFailed: FC = ({ info }) => {
+ const [t] = useTranslation("admin");
+ return (
+
+
+
+ );
+};
+
+const Authenticated: FC = ({ info }) => {
+ const [t] = useTranslation("admin");
+ return (
+
+
+
+ );
+};
+
+export default PluginCenterAuthInfo;
diff --git a/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.tsx b/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.tsx
index d7d0da255d..b737c11835 100644
--- a/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.tsx
+++ b/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.tsx
@@ -24,17 +24,15 @@
import * as React from "react";
import { FC, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Link, Plugin } from "@scm-manager/ui-types";
+import { Plugin } from "@scm-manager/ui-types";
import {
Button,
ButtonGroup,
ErrorNotification,
- Icon,
Loading,
Notification,
Subtitle,
- Title,
- Tooltip
+ Title
} from "@scm-manager/ui-components";
import PluginsList from "../components/PluginList";
import PluginTopActions from "../components/PluginTopActions";
@@ -51,6 +49,7 @@ import {
} from "@scm-manager/ui-api";
import PluginModal from "../components/PluginModal";
import MyCloudoguBanner from "../components/MyCloudoguBanner";
+import PluginCenterAuthInfo from "../components/PluginCenterAuthInfo";
export enum PluginAction {
INSTALL = "install",
@@ -81,42 +80,22 @@ const PluginsOverview: FC = ({ installed }) => {
error: installedPluginsError
} = useInstalledPlugins({ enabled: installed });
const { data: pendingPlugins, isLoading: isLoadingPendingPlugins, error: pendingPluginsError } = usePendingPlugins();
- const {
- data: pluginCenterAuthInfo,
- isLoading: isLoadingPluginCenterAuthInfo,
- error: pluginCenterAuthInfoError
- } = usePluginCenterAuthInfo();
+ const pluginCenterAuthInfo = usePluginCenterAuthInfo();
const [showPendingModal, setShowPendingModal] = useState(false);
const [showExecutePendingModal, setShowExecutePendingModal] = useState(false);
const [showUpdateAllModal, setShowUpdateAllModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
const [pluginModalContent, setPluginModalContent] = useState(null);
const collection = installed ? installedPlugins : availablePlugins;
- const error =
- (installed ? installedPluginsError : availablePluginsError) || pendingPluginsError || pluginCenterAuthInfoError;
- const loading =
- (installed ? isLoadingInstalledPlugins : isLoadingAvailablePlugins) ||
- isLoadingPendingPlugins ||
- isLoadingPluginCenterAuthInfo;
- const isPluginCenterAuthenticated = !!pluginCenterAuthInfo?.pluginCenterSubject;
- const isDefaultPluginCenter = pluginCenterAuthInfo?.default;
+ const error = (installed ? installedPluginsError : availablePluginsError) || pendingPluginsError;
+ const loading = (installed ? isLoadingInstalledPlugins : isLoadingAvailablePlugins) || isLoadingPendingPlugins;
const renderHeader = (actions: React.ReactNode) => {
return (
-
- {t("plugins.title")}
- {isPluginCenterAuthenticated && isDefaultPluginCenter ? (
-
-
-
- ) : null}
+
+ {t("plugins.title")}
@@ -208,7 +187,7 @@ const PluginsOverview: FC
= ({ installed }) => {
);
}
@@ -250,9 +229,7 @@ const PluginsOverview: FC = ({ installed }) => {
<>
{renderHeader(actions)}
- {isDefaultPluginCenter ? (
-
- ) : null}
+ {pluginCenterAuthInfo.data?.default ? : null}
{renderPluginsList()}
{renderFooter(actions)}
{renderModals()}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthResource.java
index f6719dc221..16b5e39e46 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthResource.java
@@ -53,9 +53,16 @@ import sonia.scm.user.UserDisplayManager;
import sonia.scm.util.HttpUtil;
import sonia.scm.web.VndMediaType;
+import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;
-import javax.ws.rs.*;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
@@ -81,6 +88,9 @@ public class PluginCenterAuthResource {
@VisibleForTesting
static final String ERROR_CHALLENGE_DOES_NOT_MATCH = "8ESqFElpI1";
+ private static final String METHOD_LOGIN = "login";
+ private static final String METHOD_LOGOUT = "logout";
+
private final ScmPathInfoStore pathInfoStore;
private final PluginCenterAuthenticator authenticator;
private final ScmConfiguration configuration;
@@ -107,6 +117,7 @@ public class PluginCenterAuthResource {
}
@VisibleForTesting
+ @SuppressWarnings("java:S107") // parameter count is ok for testing
PluginCenterAuthResource(
ScmPathInfoStore pathInfoStore,
PluginCenterAuthenticator authenticator,
@@ -158,20 +169,21 @@ public class PluginCenterAuthResource {
if (authentication.isPresent()) {
return Response.ok(createAuthenticatedDto(uriInfo, authentication.get())).build();
}
- PluginCenterAuthenticationInfoDto dto = new PluginCenterAuthenticationInfoDto(createLinks(uriInfo, false));
+ PluginCenterAuthenticationInfoDto dto = new PluginCenterAuthenticationInfoDto(createLinks(uriInfo, null));
dto.setDefault(configuration.isDefaultPluginAuthUrl());
return Response.ok(dto).build();
}
- private PluginCenterAuthenticationInfoDto createAuthenticatedDto(@Context UriInfo uriInfo, AuthenticationInfo info) {
+ private PluginCenterAuthenticationInfoDto createAuthenticatedDto(UriInfo uriInfo, AuthenticationInfo info) {
PluginCenterAuthenticationInfoDto dto = new PluginCenterAuthenticationInfoDto(
- createLinks(uriInfo, true)
+ createLinks(uriInfo, info)
);
dto.setPrincipal(getPrincipalDisplayName(info.getPrincipal()));
dto.setPluginCenterSubject(info.getPluginCenterSubject());
dto.setDate(info.getDate());
dto.setDefault(configuration.isDefaultPluginAuthUrl());
+ dto.setFailed(info.isFailed());
return dto;
}
@@ -197,7 +209,9 @@ public class PluginCenterAuthResource {
schema = @Schema(implementation = ErrorDto.class)
)
)
- public Response login(@Context UriInfo uriInfo, @QueryParam("source") String source) throws IOException {
+ public Response login(
+ @Context UriInfo uriInfo, @QueryParam("source") String source, @QueryParam("reconnect") boolean reconnect
+ ) throws IOException {
String pluginAuthUrl = configuration.getPluginAuthUrl();
if (Strings.isNullOrEmpty(source)) {
@@ -208,7 +222,7 @@ public class PluginCenterAuthResource {
return error(ERROR_AUTHENTICATION_DISABLED);
}
- if (authenticator.isAuthenticated()) {
+ if (!reconnect && authenticator.isAuthenticated()) {
return error(ERROR_ALREADY_AUTHENTICATED);
}
@@ -235,15 +249,22 @@ public class PluginCenterAuthResource {
return Response.seeOther(authUri).build();
}
- private Links createLinks(UriInfo uriInfo, boolean authenticated) {
+ private Links createLinks(UriInfo uriInfo, @Nullable AuthenticationInfo info) {
String self = uriInfo.getAbsolutePath().toASCIIString();
Links.Builder builder = Links.linkingTo().self(self);
if (PluginPermissions.write().isPermitted()) {
- if (authenticated) {
- builder.single(Link.link("logout", self));
+ if (info != null) {
+ builder.single(Link.link(METHOD_LOGOUT, self));
+ if (info.isFailed()) {
+ String reconnectLink = uriInfo.getAbsolutePathBuilder()
+ .path(METHOD_LOGIN)
+ .queryParam("reconnect", "true")
+ .build()
+ .toASCIIString();
+ builder.single(Link.link("reconnect", reconnectLink));
+ }
} else {
- URI login = uriInfo.getAbsolutePathBuilder().path("login").build();
- builder.single(Link.link("login", login.toASCIIString()));
+ builder.single(Link.link(METHOD_LOGIN, uriInfo.getAbsolutePathBuilder().path(METHOD_LOGIN).build().toASCIIString()));
}
}
return builder.build();
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthenticationInfoDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthenticationInfoDto.java
index f851fa0845..debe8a7c21 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthenticationInfoDto.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginCenterAuthenticationInfoDto.java
@@ -46,6 +46,7 @@ public class PluginCenterAuthenticationInfoDto extends HalRepresentation {
@JsonInclude(NON_NULL)
private Instant date;
private boolean isDefault;
+ private boolean failed;
public PluginCenterAuthenticationInfoDto(Links links) {
super(links);
diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/AuthenticationInfo.java b/scm-webapp/src/main/java/sonia/scm/plugin/AuthenticationInfo.java
index 9a8c48b654..20d9b9063c 100644
--- a/scm-webapp/src/main/java/sonia/scm/plugin/AuthenticationInfo.java
+++ b/scm-webapp/src/main/java/sonia/scm/plugin/AuthenticationInfo.java
@@ -49,4 +49,14 @@ public interface AuthenticationInfo {
* @return authentication date
*/
Instant getDate();
+
+ /**
+ * Returns {@code true} if the last authentication has failed.
+ * @return {@code true} if the last authentication has failed.
+ * @since 2.31.0
+ */
+ default boolean isFailed() {
+ return false;
+ }
+
}
diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterAuthenticationFailedEvent.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterAuthenticationFailedEvent.java
new file mode 100644
index 0000000000..894b42640a
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterAuthenticationFailedEvent.java
@@ -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.
+ */
+package sonia.scm.plugin;
+
+import lombok.Value;
+import sonia.scm.event.Event;
+
+/**
+ * Event is thrown if the authentication to the plugin center fails.
+ * @since 2.30.0
+ */
+@Event
+@Value
+public class PluginCenterAuthenticationFailedEvent implements PluginCenterAuthenticationEvent {
+ AuthenticationInfo authenticationInfo;
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterAuthenticator.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterAuthenticator.java
index c8b426a546..6fbac78b77 100644
--- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterAuthenticator.java
+++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterAuthenticator.java
@@ -34,6 +34,8 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Value;
import org.apache.shiro.SecurityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.event.ScmEventBus;
import sonia.scm.net.ahc.AdvancedHttpClient;
@@ -59,6 +61,8 @@ import static sonia.scm.plugin.Tracing.SPAN_KIND;
@Singleton
public class PluginCenterAuthenticator {
+ private static final Logger LOG = LoggerFactory.getLogger(PluginCenterAuthenticator.class);
+
@VisibleForTesting
static final String STORE_NAME = "plugin-center-auth";
@@ -86,7 +90,9 @@ public class PluginCenterAuthenticator {
PluginPermissions.write().check();
// check if refresh token is valid
- Authentication authentication = new Authentication(principal(), pluginCenterSubject, refreshToken, Instant.now());
+ Authentication authentication = new Authentication(
+ principal(), pluginCenterSubject, refreshToken, Instant.now(), false
+ );
fetchAccessToken(authentication);
eventBus.post(new PluginCenterLoginEvent(authentication));
}
@@ -109,11 +115,16 @@ public class PluginCenterAuthenticator {
return getAuthentication().map(a -> a);
}
- public String fetchAccessToken() {
+ public Optional fetchAccessToken() {
PluginPermissions.read().check();
Authentication authentication = getAuthentication()
.orElseThrow(() -> new IllegalStateException("An access token can only be obtained, after a prior authentication"));
- return fetchAccessToken(authentication);
+ try {
+ return Optional.of(fetchAccessToken(authentication));
+ } catch (FetchAccessTokenFailedException ex) {
+ LOG.warn("failed to fetch access token", ex);
+ return Optional.empty();
+ }
}
@CanIgnoreReturnValue
@@ -128,20 +139,29 @@ public class PluginCenterAuthenticator {
.request();
if (!response.isSuccessful()) {
+ authenticationFailed(authentication);
throw new FetchAccessTokenFailedException("failed to obtain access token, server returned status code " + response.getStatus());
}
RefreshResponse refresh = response.contentFromJson(RefreshResponse.class);
authentication.setRefreshToken(refresh.getRefreshToken());
+ authentication.setFailed(false);
configurationStore.set(authentication);
return refresh.getAccessToken();
} catch (IOException ex) {
+ authenticationFailed(authentication);
throw new FetchAccessTokenFailedException("failed to obtain an access token", ex);
}
}
+ private void authenticationFailed(Authentication authentication) {
+ authentication.setFailed(true);
+ configurationStore.set(authentication);
+ eventBus.post(new PluginCenterAuthenticationFailedEvent(authentication));
+ }
+
private String principal() {
return SecurityUtils.getSubject().getPrincipal().toString();
}
@@ -162,6 +182,7 @@ public class PluginCenterAuthenticator {
private String refreshToken;
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
private Instant date;
+ private boolean failed;
}
@Value
diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java
index f6045109af..3b92a962bc 100644
--- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java
+++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java
@@ -69,7 +69,7 @@ class PluginCenterLoader {
LOG.info("fetch plugins from {}", url);
AdvancedHttpRequest request = client.get(url).spanKind(SPAN_KIND);
if (authenticator.isAuthenticated()) {
- request.bearerAuth(authenticator.fetchAccessToken());
+ authenticator.fetchAccessToken().ifPresent(request::bearerAuth);
}
PluginCenterDto pluginCenterDto = request.request().contentFromJson(PluginCenterDto.class);
return mapper.map(pluginCenterDto);
diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java
index fdf463cd1d..4317df013c 100644
--- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java
+++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java
@@ -132,7 +132,7 @@ class PluginInstaller {
private InputStream download(AvailablePlugin plugin) throws IOException {
AdvancedHttpRequest request = client.get(plugin.getDescriptor().getUrl()).spanKind(SPAN_KIND);
if (authenticator.isAuthenticated()) {
- request.bearerAuth(authenticator.fetchAccessToken());
+ authenticator.fetchAccessToken().ifPresent(request::bearerAuth);
}
return request.request().contentAsStream();
}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterAuthResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterAuthResourceTest.java
index d3373194d8..d9cb0bb09e 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterAuthResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginCenterAuthResourceTest.java
@@ -147,6 +147,14 @@ class PluginCenterAuthResourceTest {
assertThat(root.get("_links").get("login").get("href").asText()).isEqualTo("/v2/plugins/auth/login");
}
+ @Test
+ @SubjectAware(value = "marvin", permissions = "plugin:write")
+ void shouldReturnReconnectAndLogoutLinkForFailedAuthentication() throws URISyntaxException, IOException {
+ JsonNode root = requestAuthInfo(true);
+
+ assertThat(root.get("_links").get("reconnect").get("href").asText()).isEqualTo("/v2/plugins/auth/login?reconnect=true");
+ }
+
@Test
void shouldReturnAuthenticationInfo() throws IOException, URISyntaxException {
JsonNode root = requestAuthInfo();
@@ -181,8 +189,12 @@ class PluginCenterAuthResourceTest {
}
private JsonNode requestAuthInfo() throws IOException, URISyntaxException {
+ return requestAuthInfo(false);
+ }
+
+ private JsonNode requestAuthInfo(boolean failed) throws IOException, URISyntaxException {
AuthenticationInfo info = new SimpleAuthenticationInfo(
- "trillian", "tricia.mcmillan@hitchhiker.com", Instant.now()
+ "trillian", "tricia.mcmillan@hitchhiker.com", Instant.now(), failed
);
when(authenticator.getAuthenticationInfo()).thenReturn(Optional.of(info));
@@ -233,6 +245,19 @@ class PluginCenterAuthResourceTest {
assertError(response, ERROR_ALREADY_AUTHENTICATED);
}
+ @Test
+ @SubjectAware("trillian")
+ void shouldIgnorePreviousAuthenticationOnReconnection() throws URISyntaxException, IOException {
+ lenient().when(authenticator.isAuthenticated()).thenReturn(true);
+ 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&reconnect=true");
+ assertRedirect(response, "https://plug.ins?instance=%2Fv2%2Fplugins%2Fauth%2Fcallback?params%3Ddef");
+ }
+
@Test
@SubjectAware("trillian")
void shouldReturnRedirectToPluginAuthUrl() throws URISyntaxException, IOException {
@@ -453,6 +478,7 @@ class PluginCenterAuthResourceTest {
String principal;
String pluginCenterSubject;
Instant date;
+ boolean failed;
}
private static final ScmPathInfo rootPathInfo = new ScmPathInfo() {
diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterAuthenticatorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterAuthenticatorTest.java
index 8e035a2c74..65121307b2 100644
--- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterAuthenticatorTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterAuthenticatorTest.java
@@ -47,6 +47,7 @@ import sonia.scm.store.InMemoryConfigurationStoreFactory;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -146,7 +147,7 @@ class PluginCenterAuthenticatorTest {
@Test
void shouldAuthenticate() throws IOException {
- mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
+ mockSuccessfulAuth("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();
@@ -154,7 +155,7 @@ class PluginCenterAuthenticatorTest {
@Test
void shouldFireLoginEvent() throws IOException {
- mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
+ mockSuccessfulAuth("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
authenticator.authenticate("tricia.mcmillan@hitchhiker.com", "my-awesome-refresh-token");
@@ -174,51 +175,89 @@ class PluginCenterAuthenticatorTest {
void shouldUseUrlFromScmConfiguration() throws IOException {
preAuth("cool-refresh-token");
scmConfiguration.setPluginAuthUrl("https://pca.org/oidc/");
- mockAuthProtocol("https://pca.org/oidc/refresh", "access", "refresh");
+ mockSuccessfulAuth("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());
+ Optional accessToken = authenticator.fetchAccessToken();
+ assertThat(accessToken).contains("access");
}
@Test
void shouldFetchAccessToken() throws IOException {
preAuth("cool-refresh-token");
- mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
+ mockSuccessfulAuth("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
- String accessToken = authenticator.fetchAccessToken();
- assertThat(accessToken).isEqualTo("access");
+ Optional accessToken = authenticator.fetchAccessToken();
+ assertThat(accessToken).contains("access");
+ }
+
+ @Test
+ void shouldReturnEmptyAccessTokenOnFailedRequest() throws IOException {
+ preAuth("cool-refresh-token");
+
+ AdvancedHttpResponse response = mockAuthResponse("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh");
+ when(response.isSuccessful()).thenReturn(false);
+
+ Optional accessToken = authenticator.fetchAccessToken();
+ assertThat(accessToken).isEmpty();
+ }
+
+ @Test
+ void shouldReturnEmptyAccessTokenOnException() throws IOException {
+ preAuth("cool-refresh-token");
+
+ when(advancedHttpClient.post("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh"))
+ .thenReturn(request);
+ when(request.request()).thenThrow(new IOException("failed"));
+
+ Optional accessToken = authenticator.fetchAccessToken();
+ assertThat(accessToken).isEmpty();
+ }
+
+ @Test
+ void shouldMarkAuthenticationAsFailed() throws IOException {
+ preAuth("cool-refresh-token");
+
+ AdvancedHttpResponse response = mockAuthResponse("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh");
+ when(response.isSuccessful()).thenReturn(false);
+
+ authenticator.fetchAccessToken();
+ assertThat(authenticator.getAuthenticationInfo()).hasValueSatisfying(
+ auth -> assertThat(auth.isFailed()).isTrue()
+ );
+ }
+
+ @Test
+ void shouldUnmarkAfterSuccessfulAuthentication() throws IOException {
+ preAuth("cool-refresh-token", true);
+ mockSuccessfulAuth("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "access", "refresh");
+
+ authenticator.fetchAccessToken();
+
+ assertThat(authenticator.getAuthenticationInfo()).hasValueSatisfying(
+ auth -> assertThat(auth.isFailed()).isFalse()
+ );
+ }
+
+ @Test
+ void shouldFireAuthenticationFailedEvent() throws IOException {
+ preAuth("cool-refresh-token");
+
+ AdvancedHttpResponse response = mockAuthResponse("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh");
+ when(response.isSuccessful()).thenReturn(false);
+
+ authenticator.fetchAccessToken();
+
+ ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(PluginCenterAuthenticationFailedEvent.class);
+ verify(eventBus).post(eventCaptor.capture());
+ PluginCenterAuthenticationFailedEvent event = eventCaptor.getValue();
+
+ assertThat(event.getAuthenticationInfo().isFailed()).isTrue();
}
@Test
void shouldStoreRefreshTokenAfterFetch() throws IOException {
preAuth("refreshOne");
- mockAuthProtocol("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "accessTwo", "refreshTwo");
+ mockSuccessfulAuth("https://plugin-center-api.scm-manager.org/api/v1/auth/oidc/refresh", "accessTwo", "refreshTwo");
authenticator.fetchAccessToken();
authenticator.fetchAccessToken();
@@ -272,22 +311,24 @@ class PluginCenterAuthenticatorTest {
assertThat(info.getPluginCenterSubject()).isEqualTo("tricia.mcmillan@hitchhiker.com");
}
- @SuppressWarnings("unchecked")
private void preAuth(String refreshToken) {
+ preAuth(refreshToken, false);
+ }
+
+ @SuppressWarnings("unchecked")
+ private void preAuth(String refreshToken, boolean failed) {
Authentication authentication = new Authentication();
authentication.setPluginCenterSubject("tricia.mcmillan@hitchhiker.com");
authentication.setPrincipal("trillian");
authentication.setRefreshToken(refreshToken);
authentication.setDate(Instant.now());
+ authentication.setFailed(failed);
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);
+ private void mockSuccessfulAuth(String url, String accessToken, String refreshToken) throws IOException {
+ AdvancedHttpResponse response = mockAuthResponse(url);
RefreshResponse refreshResponse = new RefreshResponse();
refreshResponse.setAccessToken(accessToken);
@@ -297,6 +338,15 @@ class PluginCenterAuthenticatorTest {
when(response.isSuccessful()).thenReturn(true);
}
+ private AdvancedHttpResponse mockAuthResponse(String url) throws IOException {
+ when(advancedHttpClient.post(url)).thenReturn(request);
+
+ AdvancedHttpResponse response = mock(AdvancedHttpResponse.class);
+ when(request.request()).thenReturn(response);
+
+ return response;
+ }
+
}
}
diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java
index 5873bbbe34..7adb763d10 100644
--- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java
@@ -37,6 +37,7 @@ import sonia.scm.net.ahc.AdvancedHttpResponse;
import java.io.IOException;
import java.util.Collections;
+import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -107,7 +108,7 @@ class PluginCenterLoaderTest {
@Test
void shouldAppendAccessToken() throws IOException {
when(authenticator.isAuthenticated()).thenReturn(true);
- when(authenticator.fetchAccessToken()).thenReturn("mega-cool-at");
+ when(authenticator.fetchAccessToken()).thenReturn(Optional.of("mega-cool-at"));
mockResponse();
loader.load(PLUGIN_URL);
diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java
index 4c457a3b4d..05e6d6b73b 100644
--- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java
@@ -43,6 +43,7 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Collections;
+import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -200,7 +201,7 @@ class PluginInstallerTest {
@Test
void shouldAppendBearerAuth() throws IOException {
when(authenticator.isAuthenticated()).thenReturn(true);
- when(authenticator.fetchAccessToken()).thenReturn("atat");
+ when(authenticator.fetchAccessToken()).thenReturn(Optional.of("atat"));
mockContent("42");
installer.install(PluginInstallationContext.empty(), createGitPlugin());
diff --git a/scm-webapp/src/test/java/sonia/scm/update/plugin/PluginCenterAuthentiationUpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/update/plugin/PluginCenterAuthentiationUpdateStepTest.java
index d9ca9452f2..4d5802bf89 100644
--- a/scm-webapp/src/test/java/sonia/scm/update/plugin/PluginCenterAuthentiationUpdateStepTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/update/plugin/PluginCenterAuthentiationUpdateStepTest.java
@@ -72,7 +72,7 @@ class PluginCenterAuthenticationUpdateStepTest {
@Test
void shouldUpdateIfRefreshTokenNotEncrypted() throws Exception {
when(configurationStore.getOptional())
- .thenReturn(Optional.of(new PluginCenterAuthenticator.Authentication("trillian", "trillian", "some_not_encrypted_token", Instant.now())));
+ .thenReturn(Optional.of(new PluginCenterAuthenticator.Authentication("trillian", "trillian", "some_not_encrypted_token", Instant.now(), false)));
updateStep.doUpdate();
@@ -85,7 +85,7 @@ class PluginCenterAuthenticationUpdateStepTest {
@Test
void shouldNotUpdateIfRefreshTokenIsAlreadyEncrypted() throws Exception {
when(configurationStore.getOptional())
- .thenReturn(Optional.of(new PluginCenterAuthenticator.Authentication("trillian", "trillian", "{enc}my_encrypted_token", Instant.now())));
+ .thenReturn(Optional.of(new PluginCenterAuthenticator.Authentication("trillian", "trillian", "{enc}my_encrypted_token", Instant.now(), false)));
updateStep.doUpdate();