diff --git a/scm-core/src/main/java/sonia/scm/SCMContext.java b/scm-core/src/main/java/sonia/scm/SCMContext.java index 5af5d9f7d9..42d311ed2a 100644 --- a/scm-core/src/main/java/sonia/scm/SCMContext.java +++ b/scm-core/src/main/java/sonia/scm/SCMContext.java @@ -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 diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfigurationChangedListener.java b/scm-core/src/main/java/sonia/scm/config/ScmConfigurationChangedListener.java new file mode 100644 index 0000000000..5cbda6d1e1 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfigurationChangedListener.java @@ -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); + } + } +} + + diff --git a/scm-core/src/main/java/sonia/scm/security/AnonymousToken.java b/scm-core/src/main/java/sonia/scm/security/AnonymousToken.java new file mode 100644 index 0000000000..1712a04a75 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/AnonymousToken.java @@ -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; + } +} diff --git a/scm-core/src/main/java/sonia/scm/security/Authentications.java b/scm-core/src/main/java/sonia/scm/security/Authentications.java new file mode 100644 index 0000000000..65332e20f4 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/Authentications.java @@ -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); + } +} diff --git a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java index c6a8463998..87209ce409 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java @@ -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 diff --git a/scm-core/src/test/java/sonia/scm/config/ScmConfigurationChangedListenerTest.java b/scm-core/src/test/java/sonia/scm/config/ScmConfigurationChangedListenerTest.java new file mode 100644 index 0000000000..67a275c0ad --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/config/ScmConfigurationChangedListenerTest.java @@ -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()); + } +} diff --git a/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java b/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java index d7d8a5d937..30b42fda21 100644 --- a/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java +++ b/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java @@ -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 * diff --git a/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java b/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java index 0a329b8e1e..1b48da792b 100644 --- a/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java @@ -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(); + } } diff --git a/scm-it/src/test/java/sonia/scm/it/utils/RepositoryUtil.java b/scm-it/src/test/java/sonia/scm/it/utils/RepositoryUtil.java index 427d98f245..752be88dbc 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/RepositoryUtil.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/RepositoryUtil.java @@ -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"; diff --git a/scm-it/src/test/java/sonia/scm/it/utils/RestUtil.java b/scm-it/src/test/java/sonia/scm/it/utils/RestUtil.java index 645cf06ac8..34c28a2a46 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/RestUtil.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/RestUtil.java @@ -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); + } } diff --git a/scm-it/src/test/java/sonia/scm/it/utils/ScmTypes.java b/scm-it/src/test/java/sonia/scm/it/utils/ScmTypes.java index 4c9ac0ea44..4b48c89bbc 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/ScmTypes.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmTypes.java @@ -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 availableScmTypes() { Collection params = new ArrayList<>(); @@ -18,4 +22,9 @@ public class ScmTypes { return params; } + + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return availableScmTypes().stream().map(Arguments::of); + } } diff --git a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java index c7d97a6891..cfefc1171a 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java @@ -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 READ = asList("read", "pull"); public static final Collection 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() diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLfsFilterContextListener.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLfsFilterContextListener.java index 101c242d65..c202874781 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLfsFilterContextListener.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLfsFilterContextListener.java @@ -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); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilterFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilterFactory.java index d6de8e83df..817d763d10 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilterFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilterFactory.java @@ -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); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java index fd007d0f48..37d0202d41 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java @@ -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; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java index 89362d65cd..443eedb14a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java @@ -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; diff --git a/scm-test/src/main/java/sonia/scm/repository/client/api/RepositoryClientFactory.java b/scm-test/src/main/java/sonia/scm/repository/client/api/RepositoryClientFactory.java index 17f1818e33..8dec6ec524 100644 --- a/scm-test/src/main/java/sonia/scm/repository/client/api/RepositoryClientFactory.java +++ b/scm-test/src/main/java/sonia/scm/repository/client/api/RepositoryClientFactory.java @@ -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 ---------------------------------------------------------- /** diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index 5f598cebbe..4cbfff2afc 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -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.", diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index 6b602a17be..a3720cd67c 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -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.", diff --git a/scm-ui/ui-webapp/public/locales/es/config.json b/scm-ui/ui-webapp/public/locales/es/config.json index 3b5ab843dd..8357e72546 100644 --- a/scm-ui/ui-webapp/public/locales/es/config.json +++ b/scm-ui/ui-webapp/public/locales/es/config.json @@ -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.", diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.js b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.js index 91354badf1..d5d1150944 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.js +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.js @@ -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 { loginInfoUrl, pluginUrl, enabledXsrfProtection, + anonymousAccessEnabled, namespaceStrategy, hasUpdatePermission, namespaceStrategies @@ -88,6 +89,15 @@ class GeneralSettings extends React.Component { helpText={t("help.pluginUrlHelpText")} /> +
+ +
); @@ -102,6 +112,9 @@ class GeneralSettings extends React.Component { 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"); }; diff --git a/scm-ui/ui-webapp/src/containers/App.js b/scm-ui/ui-webapp/src/containers/App.js index 1e1387fd70..fcc99e3860 100644 --- a/scm-ui/ui-webapp/src/containers/App.js +++ b/scm-ui/ui-webapp/src/containers/App.js @@ -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 { } render() { - const { - me, - loading, - error, - authenticated, - links, - t - } = this.props; + const { me, loading, error, authenticated, links, t } = this.props; let content; - const navigation = authenticated ? ( - - ) : ( - "" - ); + const navigation = authenticated ? : ""; if (loading) { content = ; @@ -85,7 +72,7 @@ class App extends Component {
{navigation}
{content} -
+ {authenticated &&
}
); } diff --git a/scm-ui/ui-webapp/src/containers/Login.js b/scm-ui/ui-webapp/src/containers/Login.js index ed231b53be..fd25c1f0a5 100644 --- a/scm-ui/ui-webapp/src/containers/Login.js +++ b/scm-ui/ui-webapp/src/containers/Login.js @@ -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 = { diff --git a/scm-ui/ui-webapp/src/modules/auth.js b/scm-ui/ui-webapp/src/modules/auth.js index fdc3c83c7f..9145017d0b 100644 --- a/scm-ui/ui-webapp/src/modules/auth.js +++ b/scm-ui/ui-webapp/src/modules/auth.js @@ -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; diff --git a/scm-ui/ui-webapp/src/modules/auth.test.js b/scm-ui/ui-webapp/src/modules/auth.test.js index cdf8e7c661..b5d4583e43 100644 --- a/scm-ui/ui-webapp/src/modules/auth.test.js +++ b/scm-ui/ui-webapp/src/modules/auth.test.js @@ -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); }); }); diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/AuthorizationExceptionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/rest/AuthorizationExceptionMapper.java index 18070b76df..6bd9884d23 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/AuthorizationExceptionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/AuthorizationExceptionMapper.java @@ -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 + implements ExceptionMapper { 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; } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index b54c831662..6653bced14 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -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())); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java index c2bebd389a..34bd035c6f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java @@ -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())); } diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java index 8c6bac8103..3aca4c4be6 100644 --- a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java @@ -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 collect(String principal) { ImmutableSet.Builder builder = ImmutableSet.builder(); - builder.add(AUTHENTICATED); + if (!Authentications.isSubjectAnonymous(principal)) { + builder.add(AUTHENTICATED); + } + builder.addAll(resolveExternalGroups(principal)); appendInternalGroups(principal, builder); diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java index bbc0dce120..512a4fc534 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java @@ -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() { diff --git a/scm-webapp/src/main/java/sonia/scm/security/AnonymousRealm.java b/scm-webapp/src/main/java/sonia/scm/security/AnonymousRealm.java new file mode 100644 index 0000000000..17a14cce73 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/AnonymousRealm.java @@ -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(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java index 28f61df34f..57866ebe3c 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAuthorizationCollector.java @@ -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()); diff --git a/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java b/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java index b44db8d62a..d297d51b45 100644 --- a/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java +++ b/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java @@ -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; diff --git a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java index 623be728c1..05eaac8d80 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/web/protocol/HttpProtocolServlet.java @@ -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); + } } } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java index d9572dc04c..42d78b99f1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java @@ -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()); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java index e9ea0bead5..f2b835a0a1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPermissionRootResourceTest.java @@ -170,6 +170,7 @@ public class RepositoryPermissionRootResourceTest extends RepositoryTestBase { @TestFactory @DisplayName("test endpoints on missing permissions and user is not Admin") Stream 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 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); diff --git a/scm-webapp/src/test/java/sonia/scm/filter/MDCFilterTest.java b/scm-webapp/src/test/java/sonia/scm/filter/MDCFilterTest.java index efd3dbbbc0..ae7f452f96 100644 --- a/scm-webapp/src/test/java/sonia/scm/filter/MDCFilterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/filter/MDCFilterTest.java @@ -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 { diff --git a/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java b/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java index 0a37361f9c..27ddd42cc1 100644 --- a/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java @@ -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 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 users = Lists.newArrayList(UserTestData.createTrillian()); + when(userManager.getAll()).thenReturn(users); + + setupContextListener.contextInitialized(null); + + verify(userManager, never()).create(SCMContext.ANONYMOUS); + } + + @Test + void shouldNotCreateAnonymousUserIfAlreadyExists() { + List 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 usernameCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor> permissionCaptor = ArgumentCaptor.forClass(Collection.class); diff --git a/scm-webapp/src/test/java/sonia/scm/security/AnonymousRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/AnonymousRealmTest.java new file mode 100644 index 0000000000..769d7406ca --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/AnonymousRealmTest.java @@ -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())); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java index 930a06d249..e15702daca 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultAuthorizationCollectorTest.java @@ -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. */ diff --git a/scm-webapp/src/test/resources/sonia/scm/shiro-001.ini b/scm-webapp/src/test/resources/sonia/scm/shiro-001.ini index 54741bcf4d..df0fd4940c 100644 --- a/scm-webapp/src/test/resources/sonia/scm/shiro-001.ini +++ b/scm-webapp/src/test/resources/sonia/scm/shiro-001.ini @@ -1,6 +1,7 @@ [users] trillian = secret, user dent = secret, admin +_anonymous = secret, user [roles] admin = *