mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-29 18:59:11 +01:00
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:
@@ -54,6 +54,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto {
|
||||
private Set<String> proxyExcludes;
|
||||
private boolean skipFailedAuthenticators;
|
||||
private String pluginUrl;
|
||||
private String pluginAuthUrl;
|
||||
private long loginAttemptLimitTimeout;
|
||||
private boolean enabledXsrfProtection;
|
||||
private boolean enabledUserConverter;
|
||||
|
||||
@@ -105,6 +105,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
|
||||
}
|
||||
|
||||
if (PluginPermissions.read().isPermitted()) {
|
||||
builder.single(link("pluginCenterAuth", resourceLinks.pluginCenterAuth().auth()));
|
||||
builder.single(link("installedPlugins", resourceLinks.installedPluginCollection().self()));
|
||||
builder.single(link("availablePlugins", resourceLinks.availablePluginCollection().self()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import de.otto.edison.hal.Links;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
@@ -97,7 +98,9 @@ public abstract class PluginDtoMapper {
|
||||
if (isCloudoguPlugin) {
|
||||
Optional<String> cloudoguInstallLink = plugin.getDescriptor().getInstallLink();
|
||||
cloudoguInstallLink.ifPresent(link -> links.single(link("cloudoguInstall", link)));
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!Strings.isNullOrEmpty(plugin.getDescriptor().getUrl())) {
|
||||
String href = resourceLinks.availablePlugin().install(information.getName());
|
||||
appendLink(links, "install", href);
|
||||
}
|
||||
|
||||
@@ -40,12 +40,19 @@ public class PluginRootResource {
|
||||
private final Provider<InstalledPluginResource> installedPluginResourceProvider;
|
||||
private final Provider<AvailablePluginResource> availablePluginResourceProvider;
|
||||
private final Provider<PendingPluginResource> pendingPluginResourceProvider;
|
||||
private final Provider<PluginCenterAuthResource> pluginCenterAuthResourceProvider;
|
||||
|
||||
@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.availablePluginResourceProvider = availablePluginResourceProvider;
|
||||
this.pendingPluginResourceProvider = pendingPluginResourceProvider;
|
||||
this.pluginCenterAuthResourceProvider = pluginCenterAuthResourceProvider;
|
||||
}
|
||||
|
||||
@Path("/installed")
|
||||
@@ -58,4 +65,9 @@ public class PluginRootResource {
|
||||
|
||||
@Path("/pending")
|
||||
public PendingPluginResource pendingPlugins() { return pendingPluginResourceProvider.get(); }
|
||||
|
||||
@Path("/auth")
|
||||
public PluginCenterAuthResource authResource() {
|
||||
return pluginCenterAuthResourceProvider.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,4 +1202,20 @@ class ResourceLinks {
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
package sonia.scm.plugin;
|
||||
|
||||
import com.github.legman.Subscribe;
|
||||
import com.google.errorprone.annotations.CanIgnoreReturnValue;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
@@ -34,8 +36,10 @@ import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.util.SystemUtil;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import java.util.Set;
|
||||
|
||||
@Singleton
|
||||
public class PluginCenter {
|
||||
|
||||
private static final String CACHE_NAME = "sonia.cache.plugins";
|
||||
@@ -55,19 +59,37 @@ public class PluginCenter {
|
||||
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() {
|
||||
String url = buildPluginUrl(configuration.getPluginUrl());
|
||||
Set<AvailablePlugin> plugins = cache.get(url);
|
||||
if (plugins == null) {
|
||||
LOG.debug("no cached available plugins found, start fetching");
|
||||
plugins = loader.load(url);
|
||||
cache.put(url, plugins);
|
||||
plugins = fetchAvailablePlugins(url);
|
||||
} else {
|
||||
LOG.debug("return available plugins from cache");
|
||||
}
|
||||
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) {
|
||||
String os = HttpUtil.encode(SystemUtil.getOS());
|
||||
String arch = SystemUtil.getArch();
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ public abstract class PluginCenterDtoMapper {
|
||||
Set<AvailablePlugin> map(PluginCenterDto pluginCenterDto) {
|
||||
Set<AvailablePlugin> plugins = new HashSet<>();
|
||||
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 installLink = getInstallLink(plugin);
|
||||
AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor(
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.event.ScmEventBus;
|
||||
import sonia.scm.net.ahc.AdvancedHttpClient;
|
||||
import sonia.scm.net.ahc.AdvancedHttpRequest;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.util.Collections;
|
||||
@@ -41,17 +42,24 @@ class PluginCenterLoader {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PluginCenterLoader.class);
|
||||
|
||||
private final AdvancedHttpClient client;
|
||||
private final PluginCenterAuthenticator authenticator;
|
||||
private final PluginCenterDtoMapper mapper;
|
||||
private final ScmEventBus eventBus;
|
||||
|
||||
@Inject
|
||||
public PluginCenterLoader(AdvancedHttpClient client, ScmEventBus eventBus) {
|
||||
this(client, PluginCenterDtoMapper.INSTANCE, eventBus);
|
||||
public PluginCenterLoader(AdvancedHttpClient client, ScmEventBus eventBus, PluginCenterAuthenticator authenticator) {
|
||||
this(client, authenticator, PluginCenterDtoMapper.INSTANCE, eventBus);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
PluginCenterLoader(AdvancedHttpClient client, PluginCenterDtoMapper mapper, ScmEventBus eventBus) {
|
||||
PluginCenterLoader(
|
||||
AdvancedHttpClient client,
|
||||
PluginCenterAuthenticator authenticator,
|
||||
PluginCenterDtoMapper mapper,
|
||||
ScmEventBus eventBus
|
||||
) {
|
||||
this.client = client;
|
||||
this.authenticator = authenticator;
|
||||
this.mapper = mapper;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
@@ -59,8 +67,11 @@ class PluginCenterLoader {
|
||||
Set<AvailablePlugin> load(String url) {
|
||||
try {
|
||||
LOG.info("fetch plugins from {}", url);
|
||||
PluginCenterDto pluginCenterDto = client.get(url).spanKind(SPAN_KIND).request()
|
||||
.contentFromJson(PluginCenterDto.class);
|
||||
AdvancedHttpRequest request = client.get(url).spanKind(SPAN_KIND);
|
||||
if (authenticator.isAuthenticated()) {
|
||||
request.bearerAuth(authenticator.fetchAccessToken());
|
||||
}
|
||||
PluginCenterDto pluginCenterDto = request.request().contentFromJson(PluginCenterDto.class);
|
||||
return mapper.map(pluginCenterDto);
|
||||
} catch (Exception ex) {
|
||||
LOG.error("failed to load plugins from plugin center, returning empty list", ex);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.google.common.hash.Hashing;
|
||||
import com.google.common.hash.HashingInputStream;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.net.ahc.AdvancedHttpClient;
|
||||
import sonia.scm.net.ahc.AdvancedHttpRequest;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.io.IOException;
|
||||
@@ -40,18 +41,19 @@ import java.util.Optional;
|
||||
|
||||
import static sonia.scm.plugin.Tracing.SPAN_KIND;
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
// guava hash is marked as unstable
|
||||
@SuppressWarnings("UnstableApiUsage") // guava hash is marked as unstable
|
||||
class PluginInstaller {
|
||||
|
||||
private final SCMContextProvider scmContext;
|
||||
private final AdvancedHttpClient client;
|
||||
private final PluginCenterAuthenticator authenticator;
|
||||
private final SmpDescriptorExtractor smpDescriptorExtractor;
|
||||
|
||||
@Inject
|
||||
public PluginInstaller(SCMContextProvider scmContext, AdvancedHttpClient client, SmpDescriptorExtractor smpDescriptorExtractor) {
|
||||
public PluginInstaller(SCMContextProvider scmContext, AdvancedHttpClient client, PluginCenterAuthenticator authenticator, SmpDescriptorExtractor smpDescriptorExtractor) {
|
||||
this.scmContext = scmContext;
|
||||
this.client = client;
|
||||
this.authenticator = authenticator;
|
||||
this.smpDescriptorExtractor = smpDescriptorExtractor;
|
||||
}
|
||||
|
||||
@@ -128,7 +130,11 @@ class PluginInstaller {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -50,16 +50,18 @@ public class XsrfAccessTokenValidator implements AccessTokenValidator {
|
||||
);
|
||||
|
||||
private final Provider<HttpServletRequest> requestProvider;
|
||||
|
||||
private final XsrfExcludes excludes;
|
||||
|
||||
/**
|
||||
* Constructs a new instance.
|
||||
*
|
||||
*
|
||||
* @param requestProvider http request provider
|
||||
* @param excludes
|
||||
*/
|
||||
@Inject
|
||||
public XsrfAccessTokenValidator(Provider<HttpServletRequest> requestProvider) {
|
||||
public XsrfAccessTokenValidator(Provider<HttpServletRequest> requestProvider, XsrfExcludes excludes) {
|
||||
this.requestProvider = requestProvider;
|
||||
this.excludes = excludes;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -67,6 +69,11 @@ public class XsrfAccessTokenValidator implements AccessTokenValidator {
|
||||
Optional<String> xsrfClaim = accessToken.getCustom(Xsrf.TOKEN_KEY);
|
||||
if (xsrfClaim.isPresent()) {
|
||||
HttpServletRequest request = requestProvider.get();
|
||||
|
||||
if (excludes.contains(request.getRequestURI())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String xsrfHeaderValue = request.getHeader(Xsrf.HEADER_KEY);
|
||||
return ALLOWED_METHOD.contains(request.getMethod().toUpperCase(Locale.ENGLISH))
|
||||
|| xsrfClaim.get().equals(xsrfHeaderValue);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user