mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-02-15 02:56:55 +01:00
Merged in feature/anonymous_access (pull request #333)
Feature/anonymous access
This commit is contained in:
@@ -39,7 +39,7 @@ import sonia.scm.user.User;
|
||||
import sonia.scm.util.ServiceUtil;
|
||||
|
||||
/**
|
||||
* The SCMConext searches a implementation of {@link SCMContextProvider} and
|
||||
* The SCMContext searches a implementation of {@link SCMContextProvider} and
|
||||
* holds a singleton instance of this implementation.
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
@@ -51,7 +51,7 @@ public final class SCMContext
|
||||
public static final String DEFAULT_PACKAGE = "sonia.scm";
|
||||
|
||||
/** Name of the anonymous user */
|
||||
public static final String USER_ANONYMOUS = "anonymous";
|
||||
public static final String USER_ANONYMOUS = "_anonymous";
|
||||
|
||||
/**
|
||||
* the anonymous user
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package sonia.scm.config;
|
||||
|
||||
import com.github.legman.Subscribe;
|
||||
import com.google.inject.Inject;
|
||||
import sonia.scm.EagerSingleton;
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.user.UserManager;
|
||||
|
||||
@Extension
|
||||
@EagerSingleton
|
||||
public class ScmConfigurationChangedListener {
|
||||
|
||||
private UserManager userManager;
|
||||
|
||||
@Inject
|
||||
public ScmConfigurationChangedListener(UserManager userManager) {
|
||||
this.userManager = userManager;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void handleEvent(ScmConfigurationChangedEvent event) {
|
||||
createAnonymousUserIfRequired(event);
|
||||
}
|
||||
|
||||
private void createAnonymousUserIfRequired(ScmConfigurationChangedEvent event) {
|
||||
if (event.getConfiguration().isAnonymousAccessEnabled() && !userManager.contains(SCMContext.USER_ANONYMOUS)) {
|
||||
userManager.create(SCMContext.ANONYMOUS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package sonia.scm.security;
|
||||
|
||||
import org.apache.shiro.authc.AuthenticationToken;
|
||||
|
||||
public class AnonymousToken implements AuthenticationToken {
|
||||
//Anonymous Token does not need an implementation
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package sonia.scm.security;
|
||||
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import sonia.scm.SCMContext;
|
||||
|
||||
public class Authentications {
|
||||
|
||||
public static boolean isAuthenticatedSubjectAnonymous() {
|
||||
return isSubjectAnonymous((String) SecurityUtils.getSubject().getPrincipal());
|
||||
}
|
||||
|
||||
public static boolean isSubjectAnonymous(String principal) {
|
||||
return SCMContext.USER_ANONYMOUS.equals(principal);
|
||||
}
|
||||
}
|
||||
@@ -37,31 +37,27 @@ package sonia.scm.web.filter;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.authc.AuthenticationException;
|
||||
import org.apache.shiro.authc.AuthenticationToken;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.AnonymousToken;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.util.Util;
|
||||
import sonia.scm.web.WebTokenGenerator;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handles authentication, if a one of the {@link WebTokenGenerator} returns
|
||||
@@ -134,6 +130,7 @@ public class AuthenticationFilter extends HttpFilter
|
||||
else if (isAnonymousAccessEnabled())
|
||||
{
|
||||
logger.trace("anonymous access granted");
|
||||
subject.login(new AnonymousToken());
|
||||
processChain(request, response, chain, subject);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package sonia.scm.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.user.UserManager;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ScmConfigurationChangedListenerTest {
|
||||
|
||||
@Mock
|
||||
UserManager userManager;
|
||||
|
||||
ScmConfiguration scmConfiguration = new ScmConfiguration();
|
||||
|
||||
@InjectMocks
|
||||
ScmConfigurationChangedListener listener = new ScmConfigurationChangedListener(userManager);
|
||||
|
||||
@Test
|
||||
void shouldCreateAnonymousUserIfAnoymousAccessEnabled() {
|
||||
when(userManager.contains(any())).thenReturn(false);
|
||||
|
||||
ScmConfiguration changes = new ScmConfiguration();
|
||||
changes.setAnonymousAccessEnabled(true);
|
||||
scmConfiguration.load(changes);
|
||||
|
||||
listener.handleEvent(new ScmConfigurationChangedEvent(scmConfiguration));
|
||||
verify(userManager).create(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotCreateAnonymousUserIfAlreadyExists() {
|
||||
when(userManager.contains(any())).thenReturn(true);
|
||||
|
||||
ScmConfiguration changes = new ScmConfiguration();
|
||||
changes.setAnonymousAccessEnabled(true);
|
||||
scmConfiguration.load(changes);
|
||||
|
||||
listener.handleEvent(new ScmConfigurationChangedEvent(scmConfiguration));
|
||||
verify(userManager, never()).create(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotCreateAnonymousUserIfAnonymousAccessDisabled() {
|
||||
//anonymous access disabled by default
|
||||
listener.handleEvent(new ScmConfigurationChangedEvent(scmConfiguration));
|
||||
verify(userManager, never()).create(any());
|
||||
}
|
||||
}
|
||||
@@ -37,33 +37,28 @@ package sonia.scm.web.filter;
|
||||
|
||||
import com.github.sdorra.shiro.ShiroRule;
|
||||
import com.github.sdorra.shiro.SubjectAware;
|
||||
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
|
||||
import org.apache.shiro.authc.AuthenticationToken;
|
||||
import org.apache.shiro.authc.UsernamePasswordToken;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.web.WebTokenGenerator;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -109,26 +104,6 @@ public class AuthenticationFilterTest
|
||||
"Authorization Required");
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @throws IOException
|
||||
* @throws ServletException
|
||||
*/
|
||||
@Test
|
||||
public void testDoFilterWithAnonymousAccess()
|
||||
throws IOException, ServletException
|
||||
{
|
||||
configuration.setAnonymousAccessEnabled(true);
|
||||
|
||||
AuthenticationFilter filter = createAuthenticationFilter();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
verify(chain).doFilter(any(HttpServletRequest.class),
|
||||
any(HttpServletResponse.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
|
||||
@@ -1,26 +1,170 @@
|
||||
package sonia.scm.it;
|
||||
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.Test;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestInstance;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.ArgumentsProvider;
|
||||
import org.junit.jupiter.params.provider.ArgumentsSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junitpioneer.jupiter.TempDirectory;
|
||||
import sonia.scm.it.utils.RepositoryUtil;
|
||||
import sonia.scm.it.utils.RestUtil;
|
||||
import sonia.scm.it.utils.ScmRequests;
|
||||
import sonia.scm.it.utils.ScmTypes;
|
||||
import sonia.scm.it.utils.TestData;
|
||||
import sonia.scm.repository.client.api.RepositoryClient;
|
||||
import sonia.scm.repository.client.api.RepositoryClientException;
|
||||
|
||||
import javax.json.Json;
|
||||
import javax.json.JsonArray;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.Collections.emptyMap;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static sonia.scm.it.utils.TestData.JSON_BUILDER;
|
||||
import static sonia.scm.it.utils.TestData.USER_ANONYMOUS;
|
||||
import static sonia.scm.it.utils.TestData.WRITE;
|
||||
import static sonia.scm.it.utils.TestData.getDefaultRepositoryUrl;
|
||||
|
||||
public class AnonymousAccessITCase {
|
||||
@ExtendWith(TempDirectory.class)
|
||||
class AnonymousAccessITCase {
|
||||
|
||||
@Test
|
||||
public void shouldAccessIndexResourceWithoutAuthentication() {
|
||||
void shouldAccessIndexResourceWithoutAuthentication() {
|
||||
ScmRequests.start()
|
||||
.requestIndexResource()
|
||||
.assertStatusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectUserResourceWithoutAuthentication() {
|
||||
void shouldRejectRepositoryResourceWithoutAuthentication() {
|
||||
assertEquals(401, RestAssured.given()
|
||||
.when()
|
||||
.get(RestUtil.REST_BASE_URL.resolve("users/"))
|
||||
.get(RestUtil.REST_BASE_URL.resolve("repositories/"))
|
||||
.statusCode());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class WithAnonymousAccess {
|
||||
@BeforeAll
|
||||
void enableAnonymousAccess() {
|
||||
setAnonymousAccess(true);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void createRepository() {
|
||||
TestData.createDefault();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGrantAnonymousAccessToRepositoryList() {
|
||||
assertEquals(200, RestAssured.given()
|
||||
.when()
|
||||
.get(RestUtil.REST_BASE_URL.resolve("repositories"))
|
||||
.statusCode());
|
||||
}
|
||||
|
||||
@Nested
|
||||
class WithoutAnonymousAccessForRepository {
|
||||
|
||||
@ParameterizedTest
|
||||
@ArgumentsSource(ScmTypes.class)
|
||||
void shouldGrantAnonymousAccessToRepository(String type) {
|
||||
assertEquals(401, RestAssured.given()
|
||||
.when()
|
||||
.get(getDefaultRepositoryUrl(type))
|
||||
.statusCode());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ArgumentsSource(ScmTypes.class)
|
||||
void shouldNotCloneRepository(String type, @TempDirectory.TempDir Path temporaryFolder) {
|
||||
assertThrows(RepositoryClientException.class, () -> RepositoryUtil.createAnonymousRepositoryClient(type, Files.createDirectories(temporaryFolder).toFile()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class WithAnonymousAccessForRepository {
|
||||
|
||||
@BeforeEach
|
||||
void grantAnonymousAccessToRepo() {
|
||||
ScmTypes.availableScmTypes().stream().forEach(type -> TestData.createUserPermission(USER_ANONYMOUS, WRITE, type));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ArgumentsSource(ScmTypes.class)
|
||||
void shouldGrantAnonymousAccessToRepository(String type) {
|
||||
assertEquals(200, RestAssured.given()
|
||||
.when()
|
||||
.get(getDefaultRepositoryUrl(type))
|
||||
.statusCode());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ArgumentsSource(ScmTypes.class)
|
||||
void shouldCloneRepository(String type, @TempDirectory.TempDir Path temporaryFolder) throws IOException {
|
||||
RepositoryClient client = RepositoryUtil.createAnonymousRepositoryClient(type, Files.createDirectories(temporaryFolder).toFile());
|
||||
assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void disableAnonymousAccess() {
|
||||
setAnonymousAccess(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setAnonymousAccess(boolean anonymousAccessEnabled) {
|
||||
RestUtil.given("application/vnd.scmm-config+json;v=2")
|
||||
.body(createConfig(anonymousAccessEnabled))
|
||||
|
||||
.when()
|
||||
.put(RestUtil.REST_BASE_URL.toASCIIString() + "config")
|
||||
|
||||
.then()
|
||||
.statusCode(HttpServletResponse.SC_NO_CONTENT);
|
||||
}
|
||||
|
||||
private static String createConfig(boolean anonymousAccessEnabled) {
|
||||
JsonArray emptyArray = Json.createBuilderFactory(emptyMap()).createArrayBuilder().build();
|
||||
return JSON_BUILDER
|
||||
.add("adminGroups", emptyArray)
|
||||
.add("adminUsers", emptyArray)
|
||||
.add("anonymousAccessEnabled", anonymousAccessEnabled)
|
||||
.add("baseUrl", "https://next-scm.cloudogu.com/scm")
|
||||
.add("dateFormat", "YYYY-MM-DD HH:mm:ss")
|
||||
.add("disableGroupingGrid", false)
|
||||
.add("enableProxy", false)
|
||||
.add("enabledXsrfProtection", true)
|
||||
.add("forceBaseUrl", false)
|
||||
.add("loginAttemptLimit", 100)
|
||||
.add("loginAttemptLimitTimeout", 300)
|
||||
.add("loginInfoUrl", "https://login-info.scm-manager.org/api/v1/login-info")
|
||||
.add("namespaceStrategy", "UsernameNamespaceStrategy")
|
||||
.add("pluginUrl", "https://oss.cloudogu.com/jenkins/job/scm-manager/job/scm-manager-bitbucket/job/plugin-snapshot/job/master/lastSuccessfulBuild/artifact/plugins/plugin-center.json")
|
||||
.add("proxyExcludes", emptyArray)
|
||||
.addNull("proxyPassword")
|
||||
.add("proxyPort", 8080)
|
||||
.add("proxyServer", "proxy.mydomain.com")
|
||||
.addNull("proxyUser")
|
||||
.add("realmDescription", "SONIA :: SCM Manager")
|
||||
.add("skipFailedAuthenticators", false)
|
||||
.build().toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ public class RepositoryUtil {
|
||||
return REPOSITORY_CLIENT_FACTORY.create(repositoryType, httpProtocolUrl, username, password, folder);
|
||||
}
|
||||
|
||||
public static RepositoryClient createAnonymousRepositoryClient(String repositoryType, File folder) throws IOException {
|
||||
String httpProtocolUrl = TestData.callRepository("scmadmin", "scmadmin", repositoryType, HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.path("_links.protocol.find{it.name=='http'}.href");
|
||||
|
||||
return REPOSITORY_CLIENT_FACTORY.create(repositoryType, httpProtocolUrl, folder);
|
||||
}
|
||||
|
||||
public static String addAndCommitRandomFile(RepositoryClient client, String username) throws IOException {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
String name = "file-" + uuid + ".uuid";
|
||||
|
||||
@@ -29,9 +29,13 @@ public class RestUtil {
|
||||
}
|
||||
|
||||
public static RequestSpecification given(String mediaType, String username, String password) {
|
||||
return RestAssured.given()
|
||||
.contentType(mediaType)
|
||||
.accept(mediaType)
|
||||
return givenAnonymous(mediaType)
|
||||
.auth().preemptive().basic(username, password);
|
||||
}
|
||||
|
||||
public static RequestSpecification givenAnonymous(String mediaType) {
|
||||
return RestAssured.given()
|
||||
.contentType(mediaType)
|
||||
.accept(mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package sonia.scm.it.utils;
|
||||
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.ArgumentsProvider;
|
||||
import sonia.scm.util.IOUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ScmTypes {
|
||||
public class ScmTypes implements ArgumentsProvider {
|
||||
public static Collection<String> availableScmTypes() {
|
||||
Collection<String> params = new ArrayList<>();
|
||||
|
||||
@@ -18,4 +22,9 @@ public class ScmTypes {
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<? extends Arguments> provideArguments(ExtensionContext context) throws Exception {
|
||||
return availableScmTypes().stream().map(Arguments::of);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.stream.Collectors;
|
||||
import static java.util.Arrays.asList;
|
||||
import static sonia.scm.it.utils.RestUtil.createResourceUrl;
|
||||
import static sonia.scm.it.utils.RestUtil.given;
|
||||
import static sonia.scm.it.utils.RestUtil.givenAnonymous;
|
||||
import static sonia.scm.it.utils.ScmTypes.availableScmTypes;
|
||||
|
||||
public class TestData {
|
||||
@@ -25,7 +26,7 @@ public class TestData {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TestData.class);
|
||||
|
||||
public static final String USER_SCM_ADMIN = "scmadmin";
|
||||
public static final String USER_ANONYMOUS = "anonymous";
|
||||
public static final String USER_ANONYMOUS = "_anonymous";
|
||||
|
||||
public static final Collection<String> READ = asList("read", "pull");
|
||||
public static final Collection<String> WRITE = asList("read", "write", "pull", "push");
|
||||
@@ -147,6 +148,16 @@ public class TestData {
|
||||
.statusCode(expectedStatusCode);
|
||||
}
|
||||
|
||||
public static ValidatableResponse callAnonymousRepository(String repositoryType, int expectedStatusCode) {
|
||||
return givenAnonymous(VndMediaType.REPOSITORY)
|
||||
|
||||
.when()
|
||||
.get(getDefaultRepositoryUrl(repositoryType))
|
||||
|
||||
.then()
|
||||
.statusCode(expectedStatusCode);
|
||||
}
|
||||
|
||||
public static String getDefaultPermissionUrl(String username, String password, String repositoryType) {
|
||||
return given(VndMediaType.REPOSITORY, username, password)
|
||||
.when()
|
||||
|
||||
@@ -28,10 +28,10 @@ import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
|
||||
public class GitLfsFilterContextListener implements ServletContextListener {
|
||||
|
||||
public static final String GITCONFIG = "[filter \"lfs\"]\n" +
|
||||
"clean = git-lfs clean -- %f\n" +
|
||||
"smudge = git-lfs smudge -- %f\n" +
|
||||
"process = git-lfs filter-process\n" +
|
||||
"required = true\n";
|
||||
"clean = git-lfs clean -- %f\n" +
|
||||
"smudge = git-lfs smudge -- %f\n" +
|
||||
"process = git-lfs filter-process\n" +
|
||||
"required = true\n";
|
||||
public static final Pattern COMMAND_NAME_PATTERN = Pattern.compile("git-lfs (smudge|clean) -- .*");
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GitLfsFilterContextListener.class);
|
||||
|
||||
@@ -3,7 +3,6 @@ package sonia.scm.repository.spi;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import sonia.scm.web.lfs.LfsBlobStoreFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Path;
|
||||
@@ -20,7 +19,8 @@ class LfsBlobStoreCleanFilterFactory {
|
||||
this.targetFile = targetFile;
|
||||
}
|
||||
|
||||
@SuppressWarnings("squid:S1172") // suppress unused parameter to keep the api compatible to jgit's FilterCommandFactory
|
||||
@SuppressWarnings("squid:S1172")
|
||||
// suppress unused parameter to keep the api compatible to jgit's FilterCommandFactory
|
||||
LfsBlobStoreCleanFilter createFilter(Repository db, InputStream in, OutputStream out) {
|
||||
return new LfsBlobStoreCleanFilter(in, out, blobStoreFactory.getLfsBlobStore(repository), targetFile);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import sonia.scm.AlreadyExistsException;
|
||||
import sonia.scm.BadRequestException;
|
||||
import sonia.scm.ConcurrentModificationException;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.ScmConstraintViolationException;
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.repository.util.WorkdirProvider;
|
||||
import sonia.scm.web.lfs.LfsBlobStoreFactory;
|
||||
|
||||
@@ -13,7 +13,6 @@ import org.eclipse.jgit.treewalk.CanonicalTreeParser;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import sonia.scm.ScmConstraintViolationException;
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.repository.util.WorkdirProvider;
|
||||
import sonia.scm.web.lfs.LfsBlobStoreFactory;
|
||||
|
||||
@@ -123,6 +123,12 @@ public final class RepositoryClientFactory
|
||||
password, workingCopy));
|
||||
}
|
||||
|
||||
public RepositoryClient create(String type, String url, File workingCopy)
|
||||
throws IOException
|
||||
{
|
||||
return new RepositoryClient(getProvider(type).create(url, null, null, workingCopy));
|
||||
}
|
||||
|
||||
//~--- get methods ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur",
|
||||
"enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.",
|
||||
"disableGroupingGridHelpText": "Repository Gruppen deaktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.",
|
||||
"allowAnonymousAccessHelpText": "Anonyme Benutzer haben Zugriff auf öffentliche Repositories.",
|
||||
"allowAnonymousAccessHelpText": "Anonyme Benutzer haben Zugriff auf freigegebene Repositories.",
|
||||
"skipFailedAuthenticatorsHelpText": "Die Kette der Authentifikatoren wird nicht beendet, wenn ein Authentifikator einen Benutzer findet, ihn aber nicht erfolgreich authentifizieren kann.",
|
||||
"adminGroupsHelpText": "Namen von Gruppen mit Admin-Berechtigungen.",
|
||||
"adminUsersHelpText": "Namen von Benutzern mit Admin-Berechtigungen.",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture",
|
||||
"enableForwardingHelpText": "Enable mod_proxy port forwarding.",
|
||||
"disableGroupingGridHelpText": "Disable repository Groups. A complete page reload is required after a change of this value.",
|
||||
"allowAnonymousAccessHelpText": "Anonymous users have read access on public repositories.",
|
||||
"allowAnonymousAccessHelpText": "Anonymous users have access on granted repositories.",
|
||||
"skipFailedAuthenticatorsHelpText": "Do not stop the authentication chain, if an authenticator finds the user but fails to authenticate the user.",
|
||||
"adminGroupsHelpText": "Names of groups with admin permissions.",
|
||||
"adminUsersHelpText": "Names of users with admin permissions.",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"pluginUrlHelpText": "La URL de la API del almacén de complementos. Explicación de los marcadores: version = Versión de SCM-Manager; os = Sistema operativo; arch = Arquitectura",
|
||||
"enableForwardingHelpText": "Habilitar el redireccionamiento de puertos para mod_proxy.",
|
||||
"disableGroupingGridHelpText": "Deshabilitar los grupos de repositorios. Se requiere una recarga completa de la página después de un cambio en este valor.",
|
||||
"allowAnonymousAccessHelpText": "Los usuarios anónimos tienen acceso de lectura en los repositorios públicos.",
|
||||
"allowAnonymousAccessHelpText": "Los usuarios anónimos tienen acceso a repositorios otorgados.",
|
||||
"skipFailedAuthenticatorsHelpText": "No detenga la cadena de autenticación si un autenticador encuentra al usuario pero no puede autenticarlo.",
|
||||
"adminGroupsHelpText": "Nombres de los grupos con permisos de administrador.",
|
||||
"adminUsersHelpText": "Nombres de los usuarios con permisos de administrador.",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { Checkbox, InputField } from "@scm-manager/ui-components";
|
||||
import type { NamespaceStrategies } from "@scm-manager/ui-types";
|
||||
import {translate} from "react-i18next";
|
||||
import {Checkbox, InputField} from "@scm-manager/ui-components";
|
||||
import type {NamespaceStrategies} from "@scm-manager/ui-types";
|
||||
import NamespaceStrategySelect from "./NamespaceStrategySelect";
|
||||
|
||||
type Props = {
|
||||
@@ -30,6 +30,7 @@ class GeneralSettings extends React.Component<Props> {
|
||||
loginInfoUrl,
|
||||
pluginUrl,
|
||||
enabledXsrfProtection,
|
||||
anonymousAccessEnabled,
|
||||
namespaceStrategy,
|
||||
hasUpdatePermission,
|
||||
namespaceStrategies
|
||||
@@ -88,6 +89,15 @@ class GeneralSettings extends React.Component<Props> {
|
||||
helpText={t("help.pluginUrlHelpText")}
|
||||
/>
|
||||
</div>
|
||||
<div className="column is-half">
|
||||
<Checkbox
|
||||
checked={anonymousAccessEnabled}
|
||||
label={t("general-settings.anonymous-access-enabled")}
|
||||
onChange={this.handleEnableAnonymousAccess}
|
||||
disabled={!hasUpdatePermission}
|
||||
helpText={t("help.allowAnonymousAccessHelpText")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -102,6 +112,9 @@ class GeneralSettings extends React.Component<Props> {
|
||||
handleEnabledXsrfProtectionChange = (value: boolean) => {
|
||||
this.props.onChange(true, value, "enabledXsrfProtection");
|
||||
};
|
||||
handleEnableAnonymousAccess = (value: boolean) => {
|
||||
this.props.onChange(true, value, "anonymousAccessEnabled");
|
||||
};
|
||||
handleNamespaceStrategyChange = (value: string) => {
|
||||
this.props.onChange(true, value, "namespaceStrategy");
|
||||
};
|
||||
|
||||
@@ -6,18 +6,18 @@ import { translate } from "react-i18next";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import {
|
||||
fetchMe,
|
||||
isAuthenticated,
|
||||
getFetchMeFailure,
|
||||
getMe,
|
||||
isFetchMePending,
|
||||
getFetchMeFailure
|
||||
isAuthenticated,
|
||||
isFetchMePending
|
||||
} from "../modules/auth";
|
||||
|
||||
import {
|
||||
PrimaryNavigation,
|
||||
Loading,
|
||||
ErrorPage,
|
||||
Footer,
|
||||
Header
|
||||
Header,
|
||||
Loading,
|
||||
PrimaryNavigation
|
||||
} from "@scm-manager/ui-components";
|
||||
import type { Links, Me } from "@scm-manager/ui-types";
|
||||
import {
|
||||
@@ -50,23 +50,10 @@ class App extends Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
me,
|
||||
loading,
|
||||
error,
|
||||
authenticated,
|
||||
links,
|
||||
t
|
||||
} = this.props;
|
||||
const { me, loading, error, authenticated, links, t } = this.props;
|
||||
|
||||
let content;
|
||||
const navigation = authenticated ? (
|
||||
<PrimaryNavigation
|
||||
links={links}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
);
|
||||
const navigation = authenticated ? <PrimaryNavigation links={links} /> : "";
|
||||
|
||||
if (loading) {
|
||||
content = <Loading />;
|
||||
@@ -85,7 +72,7 @@ class App extends Component<Props> {
|
||||
<div className="App">
|
||||
<Header>{navigation}</Header>
|
||||
{content}
|
||||
<Footer me={me} />
|
||||
{authenticated && <Footer me={me} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { compose } from "redux";
|
||||
import { translate } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
login,
|
||||
getLoginFailure,
|
||||
isAuthenticated,
|
||||
isLoginPending,
|
||||
getLoginFailure
|
||||
login
|
||||
} from "../modules/auth";
|
||||
import { getLoginLink, getLoginInfoLink } from "../modules/indexResource";
|
||||
import { getLoginInfoLink, getLoginLink } from "../modules/indexResource";
|
||||
import LoginInfo from "../components/LoginInfo";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
callFetchIndexResources,
|
||||
fetchIndexResources,
|
||||
fetchIndexResourcesPending,
|
||||
fetchIndexResourcesSuccess
|
||||
fetchIndexResourcesSuccess,
|
||||
getLoginLink
|
||||
} from "./indexResource";
|
||||
|
||||
// Action
|
||||
@@ -44,13 +45,11 @@ export default function reducer(
|
||||
case FETCH_ME_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
me: action.payload,
|
||||
authenticated: true
|
||||
me: action.payload
|
||||
};
|
||||
case FETCH_ME_UNAUTHORIZED:
|
||||
return {
|
||||
me: {},
|
||||
authenticated: false
|
||||
me: {}
|
||||
};
|
||||
case LOGOUT_SUCCESS:
|
||||
return initialState;
|
||||
@@ -240,7 +239,7 @@ const stateAuth = (state: Object): Object => {
|
||||
};
|
||||
|
||||
export const isAuthenticated = (state: Object) => {
|
||||
if (stateAuth(state).authenticated) {
|
||||
if (state.auth.me && !getLoginLink(state)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import reducer, {
|
||||
fetchMeSuccess,
|
||||
logout,
|
||||
logoutSuccess,
|
||||
loginSuccess,
|
||||
fetchMeUnauthenticated,
|
||||
LOGIN_SUCCESS,
|
||||
login,
|
||||
LOGIN_FAILURE,
|
||||
LOGOUT_FAILURE,
|
||||
LOGOUT_SUCCESS,
|
||||
FETCH_ME_SUCCESS,
|
||||
fetchMe,
|
||||
FETCH_ME,
|
||||
FETCH_ME_FAILURE,
|
||||
FETCH_ME_UNAUTHORIZED,
|
||||
isAuthenticated,
|
||||
LOGIN_PENDING,
|
||||
FETCH_ME_PENDING,
|
||||
LOGOUT_PENDING,
|
||||
FETCH_ME_SUCCESS,
|
||||
FETCH_ME_UNAUTHORIZED,
|
||||
fetchMe,
|
||||
fetchMeSuccess,
|
||||
fetchMeUnauthenticated,
|
||||
getFetchMeFailure,
|
||||
getLoginFailure,
|
||||
getLogoutFailure,
|
||||
getMe,
|
||||
isAuthenticated,
|
||||
isFetchMePending,
|
||||
isLoginPending,
|
||||
isLogoutPending,
|
||||
getFetchMeFailure,
|
||||
isRedirecting,
|
||||
login,
|
||||
LOGIN,
|
||||
FETCH_ME,
|
||||
LOGIN_FAILURE,
|
||||
LOGIN_PENDING,
|
||||
LOGIN_SUCCESS,
|
||||
loginSuccess,
|
||||
logout,
|
||||
LOGOUT,
|
||||
getLoginFailure,
|
||||
getLogoutFailure, isRedirecting, LOGOUT_REDIRECT, redirectAfterLogout,
|
||||
LOGOUT_FAILURE,
|
||||
LOGOUT_PENDING,
|
||||
LOGOUT_REDIRECT,
|
||||
LOGOUT_SUCCESS,
|
||||
logoutSuccess,
|
||||
redirectAfterLogout
|
||||
} from "./auth";
|
||||
|
||||
import configureMockStore from "redux-mock-store";
|
||||
@@ -47,22 +50,18 @@ describe("auth reducer", () => {
|
||||
it("should set me and login on successful fetch of me", () => {
|
||||
const state = reducer(undefined, fetchMeSuccess(me));
|
||||
expect(state.me).toBe(me);
|
||||
expect(state.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("should set authenticated to false", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, fetchMeUnauthenticated());
|
||||
expect(state.me.name).toBeUndefined();
|
||||
expect(state.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("should reset the state after logout", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, logoutSuccess());
|
||||
@@ -72,19 +71,16 @@ describe("auth reducer", () => {
|
||||
|
||||
it("should keep state and set redirecting to true", () => {
|
||||
const initialState = {
|
||||
authenticated: true,
|
||||
me
|
||||
};
|
||||
const state = reducer(initialState, redirectAfterLogout());
|
||||
expect(state.me).toBe(initialState.me);
|
||||
expect(state.authenticated).toBe(initialState.authenticated);
|
||||
expect(state.redirecting).toBe(true);
|
||||
});
|
||||
|
||||
it("should set state authenticated and me after login", () => {
|
||||
const state = reducer(undefined, loginSuccess(me));
|
||||
expect(state.me).toBe(me);
|
||||
expect(state.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,14 +284,19 @@ describe("auth actions", () => {
|
||||
describe("auth selectors", () => {
|
||||
const error = new Error("yo it failed");
|
||||
|
||||
it("should be false, if authenticated is undefined or false", () => {
|
||||
expect(isAuthenticated({})).toBe(false);
|
||||
expect(isAuthenticated({ auth: {} })).toBe(false);
|
||||
expect(isAuthenticated({ auth: { authenticated: false } })).toBe(false);
|
||||
it("should return true if me exist and login Link does not exist", () => {
|
||||
expect(
|
||||
isAuthenticated({ auth: { me }, indexResources: { links: {} } })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should be true, if authenticated is true", () => {
|
||||
expect(isAuthenticated({ auth: { authenticated: true } })).toBe(true);
|
||||
it("should return false if me exist and login Link does exist", () => {
|
||||
expect(
|
||||
isAuthenticated({
|
||||
auth: { me },
|
||||
indexResources: { links: { login: { href: "login.href" } } }
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return me", () => {
|
||||
@@ -359,10 +360,10 @@ describe("auth selectors", () => {
|
||||
});
|
||||
|
||||
it("should return false, if redirecting is false", () => {
|
||||
expect(isRedirecting({auth: { redirecting: false }})).toBe(false);
|
||||
expect(isRedirecting({ auth: { redirecting: false } })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true, if redirecting is true", () => {
|
||||
expect(isRedirecting({auth: { redirecting: true }})).toBe(true);
|
||||
expect(isRedirecting({ auth: { redirecting: true } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,12 +36,15 @@ package sonia.scm.api.rest;
|
||||
import org.apache.shiro.authz.AuthorizationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.security.Authentications;
|
||||
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
@@ -49,20 +52,22 @@ import javax.ws.rs.ext.Provider;
|
||||
*/
|
||||
@Provider
|
||||
public class AuthorizationExceptionMapper
|
||||
extends StatusExceptionMapper<AuthorizationException>
|
||||
implements ExceptionMapper<AuthorizationException>
|
||||
{
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AuthorizationExceptionMapper.class);
|
||||
|
||||
public AuthorizationExceptionMapper()
|
||||
{
|
||||
super(AuthorizationException.class, Response.Status.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response toResponse(AuthorizationException exception) {
|
||||
LOG.info("user is missing permission: {}", exception.getMessage());
|
||||
LOG.trace("AuthorizationException:", exception);
|
||||
return super.toResponse(exception);
|
||||
LOG.trace(getStatus().toString(), exception);
|
||||
return Response.status(getStatus())
|
||||
.entity(exception.getMessage())
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Response.Status getStatus() {
|
||||
return Authentications.isAuthenticatedSubjectAnonymous() ? Response.Status.UNAUTHORIZED : Response.Status.FORBIDDEN;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import sonia.scm.config.ConfigurationPermissions;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.group.GroupPermissions;
|
||||
import sonia.scm.plugin.PluginPermissions;
|
||||
import sonia.scm.repository.RepositoryRolePermissions;
|
||||
import sonia.scm.security.Authentications;
|
||||
import sonia.scm.security.PermissionPermissions;
|
||||
import sonia.scm.user.UserPermissions;
|
||||
|
||||
@@ -46,10 +46,14 @@ public class IndexDtoGenerator extends HalAppenderMapper {
|
||||
}
|
||||
|
||||
if (SecurityUtils.getSubject().isAuthenticated()) {
|
||||
builder.single(
|
||||
link("me", resourceLinks.me().self()),
|
||||
link("logout", resourceLinks.authentication().logout())
|
||||
);
|
||||
builder.single(link("me", resourceLinks.me().self()));
|
||||
|
||||
if (Authentications.isAuthenticatedSubjectAnonymous()) {
|
||||
builder.single(link("login", resourceLinks.authentication().jsonLogin()));
|
||||
} else {
|
||||
builder.single(link("logout", resourceLinks.authentication().logout()));
|
||||
}
|
||||
|
||||
if (PluginPermissions.read().isPermitted()) {
|
||||
builder.single(link("installedPlugins", resourceLinks.installedPluginCollection().self()));
|
||||
builder.single(link("availablePlugins", resourceLinks.availablePluginCollection().self()));
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.subject.PrincipalCollection;
|
||||
import org.apache.shiro.subject.Subject;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.security.Authentications;
|
||||
import sonia.scm.user.User;
|
||||
import sonia.scm.user.UserManager;
|
||||
import sonia.scm.user.UserPermissions;
|
||||
@@ -63,7 +64,7 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
if (UserPermissions.modify(user).isPermitted()) {
|
||||
linksBuilder.single(link("update", resourceLinks.me().update(user.getName())));
|
||||
}
|
||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted() && !Authentications.isSubjectAnonymous(user.getName())) {
|
||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.cache.Cache;
|
||||
import sonia.scm.cache.CacheManager;
|
||||
import sonia.scm.security.Authentications;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
@@ -38,7 +39,10 @@ public class DefaultGroupCollector implements GroupCollector {
|
||||
public Set<String> collect(String principal) {
|
||||
ImmutableSet.Builder<String> builder = ImmutableSet.builder();
|
||||
|
||||
builder.add(AUTHENTICATED);
|
||||
if (!Authentications.isSubjectAnonymous(principal)) {
|
||||
builder.add(AUTHENTICATED);
|
||||
}
|
||||
|
||||
builder.addAll(resolveExternalGroups(principal));
|
||||
appendInternalGroups(principal, builder);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.google.common.annotations.VisibleForTesting;
|
||||
import org.apache.shiro.authc.credential.PasswordService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.security.PermissionAssigner;
|
||||
import sonia.scm.security.PermissionDescriptor;
|
||||
@@ -47,12 +49,14 @@ public class SetupContextListener implements ServletContextListener {
|
||||
private final UserManager userManager;
|
||||
private final PasswordService passwordService;
|
||||
private final PermissionAssigner permissionAssigner;
|
||||
private final ScmConfiguration scmConfiguration;
|
||||
|
||||
@Inject
|
||||
public SetupAction(UserManager userManager, PasswordService passwordService, PermissionAssigner permissionAssigner) {
|
||||
public SetupAction(UserManager userManager, PasswordService passwordService, PermissionAssigner permissionAssigner, ScmConfiguration scmConfiguration) {
|
||||
this.userManager = userManager;
|
||||
this.passwordService = passwordService;
|
||||
this.permissionAssigner = permissionAssigner;
|
||||
this.scmConfiguration = scmConfiguration;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -60,6 +64,13 @@ public class SetupContextListener implements ServletContextListener {
|
||||
if (isFirstStart()) {
|
||||
createAdminAccount();
|
||||
}
|
||||
if (anonymousUserRequiredButNotExists()) {
|
||||
userManager.create(SCMContext.ANONYMOUS);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean anonymousUserRequiredButNotExists() {
|
||||
return scmConfiguration.isAnonymousAccessEnabled() && !userManager.contains(SCMContext.USER_ANONYMOUS);
|
||||
}
|
||||
|
||||
private boolean isFirstStart() {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package sonia.scm.security;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.inject.Inject;
|
||||
import org.apache.shiro.authc.AuthenticationInfo;
|
||||
import org.apache.shiro.authc.AuthenticationToken;
|
||||
import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher;
|
||||
import org.apache.shiro.realm.AuthenticatingRealm;
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.plugin.Extension;
|
||||
|
||||
import javax.inject.Singleton;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkArgument;
|
||||
|
||||
@Singleton
|
||||
@Extension
|
||||
public class AnonymousRealm extends AuthenticatingRealm {
|
||||
|
||||
/**
|
||||
* realm name
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static final String REALM = "AnonymousRealm";
|
||||
|
||||
/**
|
||||
* dao realm helper
|
||||
*/
|
||||
private final DAORealmHelper helper;
|
||||
|
||||
@Inject
|
||||
public AnonymousRealm(DAORealmHelperFactory helperFactory) {
|
||||
this.helper = helperFactory.create(REALM);
|
||||
|
||||
setAuthenticationTokenClass(AnonymousToken.class);
|
||||
setCredentialsMatcher(new AllowAllCredentialsMatcher());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) {
|
||||
checkArgument(authenticationToken instanceof AnonymousToken, "%s is required", AnonymousToken.class);
|
||||
return helper.authenticationInfoBuilder(SCMContext.USER_ANONYMOUS).build();
|
||||
}
|
||||
}
|
||||
@@ -254,9 +254,11 @@ public class DefaultAuthorizationCollector implements AuthorizationCollector
|
||||
collectGlobalPermissions(builder, user, groups);
|
||||
collectRepositoryPermissions(builder, user, groups);
|
||||
builder.add(canReadOwnUser(user));
|
||||
builder.add(getUserAutocompletePermission());
|
||||
builder.add(getGroupAutocompletePermission());
|
||||
builder.add(getChangeOwnPasswordPermission(user));
|
||||
if (!Authentications.isSubjectAnonymous(user.getName())) {
|
||||
builder.add(getUserAutocompletePermission());
|
||||
builder.add(getGroupAutocompletePermission());
|
||||
builder.add(getChangeOwnPasswordPermission(user));
|
||||
}
|
||||
|
||||
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(ImmutableSet.of(Role.USER));
|
||||
info.addStringPermissions(builder.build());
|
||||
|
||||
@@ -48,15 +48,11 @@ import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.TransformFilter;
|
||||
import sonia.scm.search.SearchRequest;
|
||||
import sonia.scm.search.SearchUtil;
|
||||
import sonia.scm.security.Authentications;
|
||||
import sonia.scm.util.CollectionAppender;
|
||||
import sonia.scm.util.IOUtil;
|
||||
import sonia.scm.util.Util;
|
||||
|
||||
import javax.xml.bind.JAXBContext;
|
||||
import javax.xml.bind.JAXBException;
|
||||
import javax.xml.bind.Unmarshaller;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
@@ -383,7 +379,7 @@ public class DefaultUserManager extends AbstractUserManager
|
||||
public void changePasswordForLoggedInUser(String oldPassword, String newPassword) {
|
||||
User user = get((String) SecurityUtils.getSubject().getPrincipals().getPrimaryPrincipal());
|
||||
|
||||
if (!user.getPassword().equals(oldPassword)) {
|
||||
if (!isAnonymousUser(user) && !user.getPassword().equals(oldPassword)) {
|
||||
throw new InvalidPasswordException(ContextEntry.ContextBuilder.entity("PasswordChange", "-").in(User.class, user.getName()));
|
||||
}
|
||||
|
||||
@@ -402,13 +398,17 @@ public class DefaultUserManager extends AbstractUserManager
|
||||
if (user == null) {
|
||||
throw new NotFoundException(User.class, userId);
|
||||
}
|
||||
if (!isTypeDefault(user)) {
|
||||
if (!isTypeDefault(user) || isAnonymousUser(user)) {
|
||||
throw new ChangePasswordNotAllowedException(ContextEntry.ContextBuilder.entity("PasswordChange", "-").in(User.class, user.getName()), user.getType());
|
||||
}
|
||||
user.setPassword(newPassword);
|
||||
this.modify(user);
|
||||
}
|
||||
|
||||
private boolean isAnonymousUser(User user) {
|
||||
return Authentications.isSubjectAnonymous(user.getName());
|
||||
}
|
||||
|
||||
//~--- fields ---------------------------------------------------------------
|
||||
|
||||
private final UserDAO userDAO;
|
||||
|
||||
@@ -13,6 +13,8 @@ import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
import sonia.scm.repository.spi.HttpScmProtocol;
|
||||
import sonia.scm.security.Authentications;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.UserAgent;
|
||||
import sonia.scm.web.UserAgentParser;
|
||||
|
||||
@@ -73,7 +75,11 @@ public class HttpProtocolServlet extends HttpServlet {
|
||||
resp.setStatus(HttpStatus.SC_NOT_FOUND);
|
||||
} catch (AuthorizationException e) {
|
||||
log.debug(e.getMessage());
|
||||
resp.setStatus(HttpStatus.SC_FORBIDDEN);
|
||||
if (Authentications.isAuthenticatedSubjectAnonymous()) {
|
||||
HttpUtil.sendUnauthorized(resp);
|
||||
} else {
|
||||
resp.setStatus(HttpStatus.SC_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,17 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
import sonia.scm.user.User;
|
||||
import sonia.scm.user.UserManager;
|
||||
import sonia.scm.user.UserPermissions;
|
||||
import sonia.scm.user.UserTestData;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -159,6 +162,18 @@ class MeDtoFactoryTest {
|
||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotGetPasswordLinkForAnonymousUser() {
|
||||
User user = SCMContext.ANONYMOUS;
|
||||
prepareSubject(user);
|
||||
|
||||
when(userManager.isTypeDefault(any())).thenReturn(true);
|
||||
when(UserPermissions.changePassword(user).isPermitted()).thenReturn(true);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAppendLinks() {
|
||||
prepareSubject(UserTestData.createTrillian());
|
||||
|
||||
@@ -170,6 +170,7 @@ public class RepositoryPermissionRootResourceTest extends RepositoryTestBase {
|
||||
@TestFactory
|
||||
@DisplayName("test endpoints on missing permissions and user is not Admin")
|
||||
Stream<DynamicTest> missedPermissionUserForbiddenTestFactory() {
|
||||
when(subject.getPrincipal()).thenReturn("user");
|
||||
doThrow(AuthorizationException.class).when(repositoryManager).get(any(NamespaceAndName.class));
|
||||
return createDynamicTestsToAssertResponses(
|
||||
requestGETPermission.expectedResponseStatus(403),
|
||||
@@ -179,6 +180,19 @@ public class RepositoryPermissionRootResourceTest extends RepositoryTestBase {
|
||||
requestPUTPermission.expectedResponseStatus(403));
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
@DisplayName("test endpoints on missing permissions and is _anonymous")
|
||||
Stream<DynamicTest> missedPermissionAnonymousUnauthorizedTestFactory() {
|
||||
when(subject.getPrincipal()).thenReturn("_anonymous");
|
||||
doThrow(AuthorizationException.class).when(repositoryManager).get(any(NamespaceAndName.class));
|
||||
return createDynamicTestsToAssertResponses(
|
||||
requestGETPermission.expectedResponseStatus(401),
|
||||
requestPOSTPermission.expectedResponseStatus(401),
|
||||
requestGETAllPermissions.expectedResponseStatus(401),
|
||||
requestDELETEPermission.expectedResponseStatus(401),
|
||||
requestPUTPermission.expectedResponseStatus(401));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void userWithPermissionWritePermissionShouldGetAllPermissionsWithCreateAndUpdateLinks() throws URISyntaxException {
|
||||
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE);
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import org.slf4j.MDC;
|
||||
import sonia.scm.AbstractTestBase;
|
||||
import sonia.scm.SCMContext;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
@@ -117,7 +118,7 @@ public class MDCFilterTest extends AbstractTestBase {
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertNotNull(chain.ctx);
|
||||
assertEquals("anonymous", chain.ctx.get(MDCFilter.MDC_USERNAME));
|
||||
assertEquals(SCMContext.USER_ANONYMOUS, chain.ctx.get(MDCFilter.MDC_USERNAME));
|
||||
}
|
||||
|
||||
private static class MDCCapturingFilterChain implements FilterChain {
|
||||
|
||||
@@ -11,6 +11,8 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.PermissionAssigner;
|
||||
import sonia.scm.security.PermissionDescriptor;
|
||||
import sonia.scm.user.User;
|
||||
@@ -23,7 +25,12 @@ import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SetupContextListenerTest {
|
||||
@@ -40,12 +47,20 @@ class SetupContextListenerTest {
|
||||
@Mock
|
||||
private PasswordService passwordService;
|
||||
|
||||
@Mock
|
||||
ScmConfiguration scmConfiguration;
|
||||
|
||||
@Mock
|
||||
private PermissionAssigner permissionAssigner;
|
||||
|
||||
@InjectMocks
|
||||
private SetupContextListener.SetupAction setupAction;
|
||||
|
||||
@BeforeEach
|
||||
void mockScmConfiguration() {
|
||||
when(scmConfiguration.isAnonymousAccessEnabled()).thenReturn(false);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setupObjectUnderTest() {
|
||||
doAnswer(ic -> {
|
||||
@@ -90,6 +105,38 @@ class SetupContextListenerTest {
|
||||
verify(permissionAssigner, never()).setPermissionsForUser(anyString(), any(Collection.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateAnonymousUserIfRequired() {
|
||||
List<User> users = Lists.newArrayList(UserTestData.createTrillian());
|
||||
when(userManager.getAll()).thenReturn(users);
|
||||
when(scmConfiguration.isAnonymousAccessEnabled()).thenReturn(true);
|
||||
|
||||
setupContextListener.contextInitialized(null);
|
||||
|
||||
verify(userManager).create(SCMContext.ANONYMOUS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotCreateAnonymousUserIfNotRequired() {
|
||||
List<User> users = Lists.newArrayList(UserTestData.createTrillian());
|
||||
when(userManager.getAll()).thenReturn(users);
|
||||
|
||||
setupContextListener.contextInitialized(null);
|
||||
|
||||
verify(userManager, never()).create(SCMContext.ANONYMOUS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotCreateAnonymousUserIfAlreadyExists() {
|
||||
List<User> users = Lists.newArrayList(SCMContext.ANONYMOUS);
|
||||
when(userManager.getAll()).thenReturn(users);
|
||||
when(scmConfiguration.isAnonymousAccessEnabled()).thenReturn(true);
|
||||
|
||||
setupContextListener.contextInitialized(null);
|
||||
|
||||
verify(userManager, times(1)).create(SCMContext.ANONYMOUS);
|
||||
}
|
||||
|
||||
private void verifyAdminPermissionsAssigned() {
|
||||
ArgumentCaptor<String> usernameCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<Collection<PermissionDescriptor>> permissionCaptor = ArgumentCaptor.forClass(Collection.class);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package sonia.scm.security;
|
||||
|
||||
import org.apache.shiro.authc.AuthenticationInfo;
|
||||
import org.apache.shiro.authc.UsernamePasswordToken;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.SCMContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AnonymousRealmTest {
|
||||
|
||||
@Mock
|
||||
private DAORealmHelperFactory realmHelperFactory;
|
||||
|
||||
@Mock
|
||||
private DAORealmHelper realmHelper;
|
||||
|
||||
@Mock
|
||||
private DAORealmHelper.AuthenticationInfoBuilder builder;
|
||||
|
||||
@InjectMocks
|
||||
private AnonymousRealm realm;
|
||||
|
||||
@Mock
|
||||
private AuthenticationInfo authenticationInfo;
|
||||
|
||||
@BeforeEach
|
||||
void prepareObjectUnderTest() {
|
||||
when(realmHelperFactory.create(AnonymousRealm.REALM)).thenReturn(realmHelper);
|
||||
realm = new AnonymousRealm(realmHelperFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDoGetAuthentication() {
|
||||
when(realmHelper.authenticationInfoBuilder(SCMContext.USER_ANONYMOUS)).thenReturn(builder);
|
||||
when(builder.build()).thenReturn(authenticationInfo);
|
||||
|
||||
AuthenticationInfo result = realm.doGetAuthenticationInfo(new AnonymousToken());
|
||||
assertThat(result).isSameAs(authenticationInfo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowIllegalArgumentExceptionForWrongTypeOfToken() {
|
||||
assertThrows(IllegalArgumentException.class, () -> realm.doGetAuthenticationInfo(new UsernamePasswordToken()));
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import org.junit.runner.RunWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import sonia.scm.SCMContext;
|
||||
import sonia.scm.cache.Cache;
|
||||
import sonia.scm.cache.CacheManager;
|
||||
import sonia.scm.group.GroupCollector;
|
||||
@@ -172,6 +173,23 @@ public class DefaultAuthorizationCollectorTest {
|
||||
assertThat(authInfo.getObjectPermissions(), nullValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} without permissions.
|
||||
*/
|
||||
@Test
|
||||
@SubjectAware(
|
||||
configuration = "classpath:sonia/scm/shiro-001.ini"
|
||||
)
|
||||
public void testCollectWithoutPermissionsForAnonymousUser() {
|
||||
authenticate(SCMContext.ANONYMOUS, "anon");
|
||||
|
||||
AuthorizationInfo authInfo = collector.collect();
|
||||
assertThat(authInfo.getRoles(), Matchers.contains(Role.USER));
|
||||
assertThat(authInfo.getStringPermissions(), hasSize(1));
|
||||
assertThat(authInfo.getStringPermissions(), containsInAnyOrder("user:read:_anonymous"));
|
||||
assertThat(authInfo.getObjectPermissions(), nullValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests {@link AuthorizationCollector#collect(PrincipalCollection)} ()} with repository permissions.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[users]
|
||||
trillian = secret, user
|
||||
dent = secret, admin
|
||||
_anonymous = secret, user
|
||||
|
||||
[roles]
|
||||
admin = *
|
||||
|
||||
Reference in New Issue
Block a user