Integrate Plugin Center myCloudogu Authentication (#1884)

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@
package sonia.scm.api.v2.resources;
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);
}

View File

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

View File

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