From 2bcd1b062b5f26b9e325b3bd2b744d5c56704352 Mon Sep 17 00:00:00 2001 From: Julien Lancelot Date: Tue, 19 Jan 2016 11:44:16 +0100 Subject: [PATCH] SONAR-6226 Create IdentityProvider API --- .../it/authorisation/AuthenticationTest.java | 7 +- pom.xml | 1 - server/sonar-server/pom.xml | 1 + .../authentication/AuthenticationError.java | 65 +++++++ .../authentication/AuthenticationModule.java | 38 +++++ .../authentication/BaseContextFactory.java | 73 ++++++++ .../server/authentication/CsrfVerifier.java | 80 +++++++++ .../IdentityProviderRepository.java | 91 ++++++++++ .../server/authentication/InitFilter.java | 95 +++++++++++ .../NotAllowUserToSignUpException.java | 35 ++++ .../authentication/OAuth2CallbackFilter.java | 84 +++++++++ .../authentication/OAuth2ContextFactory.java | 115 +++++++++++++ .../UserIdentityAuthenticator.java | 81 +++++++++ .../server/authentication/package-info.java | 23 +++ .../org/sonar/server/platform/ServerImpl.java | 42 +++-- .../platformlevel/PlatformLevel4.java | 4 +- .../java/org/sonar/server/ui/JRubyFacade.java | 6 + .../org/sonar/server/user/UserUpdater.java | 24 +-- .../BaseContextFactoryTest.java | 78 +++++++++ .../authentication/CsrfVerifierTest.java | 127 ++++++++++++++ .../FakeBasicIdentityProvider.java | 42 +++++ .../FakeOAuth2IdentityProvider.java | 51 ++++++ .../IdentityProviderRepositoryRule.java | 48 ++++++ .../IdentityProviderRepositoryTest.java | 90 ++++++++++ .../server/authentication/InitFilterTest.java | 160 ++++++++++++++++++ .../OAuth2CallbackFilterTest.java | 114 +++++++++++++ .../OAuth2ContextFactoryTest.java | 148 ++++++++++++++++ .../authentication/TestIdentityProvider.java | 99 +++++++++++ .../UserIdentityAuthenticatorTest.java | 149 ++++++++++++++++ .../sonar/server/platform/ServerImplTest.java | 36 +++- .../platform/ServerLifecycleNotifierTest.java | 23 ++- .../server/platform/ws/StatusActionTest.java | 15 ++ .../app/controllers/sessions_controller.rb | 7 + .../WEB-INF/app/views/sessions/_form.html.erb | 26 ++- .../sessions/not_allowed_to_sign_up.html.erb | 10 ++ .../app/views/sessions/unauthorized.html.erb | 14 ++ .../WEB-INF/lib/authenticated_system.rb | 8 +- .../sonar/batch/platform/DefaultServer.java | 32 ++-- .../java/org/sonar/api/platform/Server.java | 30 +++- .../authentication/BaseIdentityProvider.java | 67 ++++++++ .../authentication/IdentityProvider.java | 70 ++++++++ .../OAuth2IdentityProvider.java | 95 +++++++++++ .../server/authentication/UserIdentity.java | 129 ++++++++++++++ .../server/authentication/package-info.java | 23 +++ .../authentication/UserIdentityTest.java | 142 ++++++++++++++++ 45 files changed, 2640 insertions(+), 58 deletions(-) create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationError.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/BaseContextFactory.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/IdentityProviderRepository.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/InitFilter.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/NotAllowUserToSignUpException.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2CallbackFilter.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2ContextFactory.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/UserIdentityAuthenticator.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/package-info.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/BaseContextFactoryTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/CsrfVerifierTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/FakeBasicIdentityProvider.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/FakeOAuth2IdentityProvider.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryRule.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/InitFilterTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2CallbackFilterTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2ContextFactoryTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/TestIdentityProvider.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/UserIdentityAuthenticatorTest.java create mode 100644 server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/not_allowed_to_sign_up.html.erb create mode 100644 server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/unauthorized.html.erb create mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/BaseIdentityProvider.java create mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/IdentityProvider.java create mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/OAuth2IdentityProvider.java create mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/UserIdentity.java create mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/package-info.java create mode 100644 sonar-plugin-api/src/test/java/org/sonar/api/server/authentication/UserIdentityTest.java diff --git a/it/it-tests/src/test/java/it/authorisation/AuthenticationTest.java b/it/it-tests/src/test/java/it/authorisation/AuthenticationTest.java index 818f5cf37fd..1ed047c08bb 100644 --- a/it/it-tests/src/test/java/it/authorisation/AuthenticationTest.java +++ b/it/it-tests/src/test/java/it/authorisation/AuthenticationTest.java @@ -118,11 +118,8 @@ public class AuthenticationTest { assertThat(searchResponse.getUserTokensCount()).isEqualTo(0); } - /** - * This is currently a limitation of Ruby on Rails stack. - */ @Test - public void basic_authentication_does_not_support_utf8_passwords() { + public void basic_authentication_with_utf8_passwords() { String userId = UUID.randomUUID().toString(); String login = format("login-%s", userId); // see http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt @@ -134,7 +131,7 @@ public class AuthenticationTest { // authenticate WsClient wsClient = new HttpWsClient(new HttpConnector.Builder().url(ORCHESTRATOR.getServer().getUrl()).credentials(login, password).build()); WsResponse response = wsClient.wsConnector().call(new GetRequest("api/authentication/validate")); - assertThat(response.content()).isEqualTo("{\"valid\":false}"); + assertThat(response.content()).isEqualTo("{\"valid\":true}"); } @Test diff --git a/pom.xml b/pom.xml index 8567ca2c751..b438f480407 100644 --- a/pom.xml +++ b/pom.xml @@ -919,7 +919,6 @@ test - org.apache.tomcat.embed diff --git a/server/sonar-server/pom.xml b/server/sonar-server/pom.xml index e03b5d89805..aaaae163989 100644 --- a/server/sonar-server/pom.xml +++ b/server/sonar-server/pom.xml @@ -185,6 +185,7 @@ ${project.groupId} sonar-dev-cockpit-bridge + ${project.groupId} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationError.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationError.java new file mode 100644 index 00000000000..35e4bcc3d74 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationError.java @@ -0,0 +1,65 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import java.io.IOException; +import javax.servlet.http.HttpServletResponse; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; + +import static java.lang.String.format; + +public class AuthenticationError { + + private static final Logger LOGGER = Loggers.get(AuthenticationError.class); + + private static final String UNAUTHORIZED_PATH = "/sessions/unauthorized"; + private static final String NOT_ALLOWED_TO_SIGHNUP_PATH = "/sessions/not_allowed_to_sign_up?providerName=%s"; + + private AuthenticationError() { + // Utility class + } + + public static void handleError(Exception e, HttpServletResponse response, String message) { + LOGGER.error(message, e); + redirectToUnauthorized(response); + } + + public static void handleError(HttpServletResponse response, String message) { + LOGGER.error(message); + redirectToUnauthorized(response); + } + + public static void handleNotAllowedToSignUpError(NotAllowUserToSignUpException e, HttpServletResponse response) { + redirectTo(response, format(NOT_ALLOWED_TO_SIGHNUP_PATH, e.getProvider().getName())); + } + + private static void redirectToUnauthorized(HttpServletResponse response) { + redirectTo(response, UNAUTHORIZED_PATH); + } + + private static void redirectTo(HttpServletResponse response, String url) { + try { + response.sendRedirect(url); + } catch (IOException e) { + throw new IllegalStateException(format("Fail to redirect to %s", url), e); + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java new file mode 100644 index 00000000000..2ecebed37bd --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java @@ -0,0 +1,38 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import org.sonar.core.platform.Module; +import org.sonar.server.authentication.ws.AuthenticationWs; + +public class AuthenticationModule extends Module { + @Override + protected void configureModule() { + add( + AuthenticationWs.class, + InitFilter.class, + OAuth2CallbackFilter.class, + IdentityProviderRepository.class, + BaseContextFactory.class, + OAuth2ContextFactory.class, + UserIdentityAuthenticator.class, + CsrfVerifier.class); + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/BaseContextFactory.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/BaseContextFactory.java new file mode 100644 index 00000000000..465f093f903 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/BaseContextFactory.java @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.sonar.api.platform.Server; +import org.sonar.api.server.authentication.BaseIdentityProvider; +import org.sonar.api.server.authentication.UserIdentity; + +public class BaseContextFactory { + + private final UserIdentityAuthenticator userIdentityAuthenticator; + private final Server server; + + public BaseContextFactory(UserIdentityAuthenticator userIdentityAuthenticator, Server server) { + this.userIdentityAuthenticator = userIdentityAuthenticator; + this.server = server; + } + + public BaseIdentityProvider.Context newContext(HttpServletRequest request, HttpServletResponse response, BaseIdentityProvider identityProvider) { + return new ContextImpl(request, response, identityProvider); + } + + private class ContextImpl implements BaseIdentityProvider.Context { + private final HttpServletRequest request; + private final HttpServletResponse response; + private final BaseIdentityProvider identityProvider; + + public ContextImpl(HttpServletRequest request, HttpServletResponse response, BaseIdentityProvider identityProvider) { + this.request = request; + this.response = response; + this.identityProvider = identityProvider; + } + + @Override + public HttpServletRequest getRequest() { + return request; + } + + @Override + public HttpServletResponse getResponse() { + return response; + } + + @Override + public String getServerBaseURL() { + return server.getPublicRootUrl(); + } + + @Override + public void authenticate(UserIdentity userIdentity) { + userIdentityAuthenticator.authenticate(userIdentity, identityProvider, request.getSession()); + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java new file mode 100644 index 00000000000..9a0371506fb --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java @@ -0,0 +1,80 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import java.math.BigInteger; +import java.security.SecureRandom; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.sonar.api.platform.Server; +import org.sonar.server.exceptions.UnauthorizedException; + +import static org.apache.commons.codec.digest.DigestUtils.sha256Hex; +import static org.apache.commons.lang.StringUtils.isBlank; + +public class CsrfVerifier { + + private static final String CSRF_STATE_COOKIE = "OAUTHSTATE"; + + private final Server server; + + public CsrfVerifier(Server server) { + this.server = server; + } + + public String generateState(HttpServletResponse response) { + // Create a state token to prevent request forgery. + // Store it in the session for later validation. + String state = new BigInteger(130, new SecureRandom()).toString(32); + Cookie cookie = new Cookie(CSRF_STATE_COOKIE, sha256Hex(state)); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(-1); + cookie.setSecure(server.isSecured()); + response.addCookie(cookie); + return state; + } + + public void verifyState(HttpServletRequest request, HttpServletResponse response) { + Cookie stateCookie = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + if (CSRF_STATE_COOKIE.equals(cookie.getName())) { + stateCookie = cookie; + } + } + if (stateCookie == null) { + throw new UnauthorizedException(); + } + String hashInCookie = stateCookie.getValue(); + + // remove cookie + stateCookie.setValue(null); + stateCookie.setMaxAge(0); + stateCookie.setPath("/"); + response.addCookie(stateCookie); + + String stateInRequest = request.getParameter("state"); + if (isBlank(stateInRequest) || !sha256Hex(stateInRequest).equals(hashInCookie)) { + throw new UnauthorizedException(); + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/IdentityProviderRepository.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/IdentityProviderRepository.java new file mode 100644 index 00000000000..b20acc191d9 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/IdentityProviderRepository.java @@ -0,0 +1,91 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import com.google.common.base.Function; +import com.google.common.base.Predicate; +import com.google.common.collect.FluentIterable; +import com.google.common.collect.Ordering; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.sonar.api.server.authentication.IdentityProvider; + +import static com.google.common.collect.FluentIterable.from; + +public class IdentityProviderRepository { + + protected final Map providersByKey = new HashMap<>(); + + public IdentityProviderRepository(List identityProviders) { + this.providersByKey.putAll(FluentIterable.from(identityProviders).uniqueIndex(ToKey.INSTANCE)); + } + + /** + * Used by pico when no identity provider available + */ + public IdentityProviderRepository() { + this.providersByKey.clear(); + } + + public IdentityProvider getEnabledByKey(String key) { + IdentityProvider identityProvider = providersByKey.get(key); + if (identityProvider != null && IsEnabledFilter.INSTANCE.apply(identityProvider)) { + return identityProvider; + } + throw new IllegalArgumentException(String.format("Identity provider %s does not exist or is not enabled", key)); + } + + public List getAllEnabledAndSorted() { + return from(providersByKey.values()) + .filter(IsEnabledFilter.INSTANCE) + .toSortedList( + Ordering.natural().onResultOf(ToName.INSTANCE) + ); + } + + private enum IsEnabledFilter implements Predicate { + INSTANCE; + + @Override + public boolean apply(@Nonnull IdentityProvider input) { + return input.isEnabled(); + } + } + + private enum ToKey implements Function { + INSTANCE; + + @Override + public String apply(@Nonnull IdentityProvider input) { + return input.getKey(); + } + } + + private enum ToName implements Function { + INSTANCE; + + @Override + public String apply(@Nonnull IdentityProvider input) { + return input.getName(); + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/InitFilter.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/InitFilter.java new file mode 100644 index 00000000000..65b0eee5cb6 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/InitFilter.java @@ -0,0 +1,95 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.sonar.api.server.authentication.BaseIdentityProvider; +import org.sonar.api.server.authentication.IdentityProvider; +import org.sonar.api.server.authentication.OAuth2IdentityProvider; +import org.sonar.api.web.ServletFilter; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static java.lang.String.format; +import static org.sonar.server.authentication.AuthenticationError.handleError; +import static org.sonar.server.authentication.AuthenticationError.handleNotAllowedToSignUpError; + +public class InitFilter extends ServletFilter { + + private static final String INIT_CONTEXT = "/sessions/init"; + + private final IdentityProviderRepository identityProviderRepository; + private final BaseContextFactory baseContextFactory; + private final OAuth2ContextFactory oAuth2ContextFactory; + + public InitFilter(IdentityProviderRepository identityProviderRepository, BaseContextFactory baseContextFactory, OAuth2ContextFactory oAuth2ContextFactory) { + this.identityProviderRepository = identityProviderRepository; + this.baseContextFactory = baseContextFactory; + this.oAuth2ContextFactory = oAuth2ContextFactory; + } + + @Override + public UrlPattern doGetPattern() { + return UrlPattern.create(INIT_CONTEXT + "/*"); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + String requestUri = httpRequest.getRequestURI(); + final String keyProvider = requestUri.replace(INIT_CONTEXT + "/", ""); + try { + if (isNullOrEmpty(keyProvider)) { + throw new IllegalArgumentException("A valid identity provider key is required"); + } + + IdentityProvider provider = identityProviderRepository.getEnabledByKey(keyProvider); + if (provider instanceof BaseIdentityProvider) { + BaseIdentityProvider baseIdentityProvider = (BaseIdentityProvider) provider; + baseIdentityProvider.init(baseContextFactory.newContext(httpRequest, (HttpServletResponse) response, baseIdentityProvider)); + } else if (provider instanceof OAuth2IdentityProvider) { + OAuth2IdentityProvider oAuth2IdentityProvider = (OAuth2IdentityProvider) provider; + oAuth2IdentityProvider.init(oAuth2ContextFactory.newContext(httpRequest, (HttpServletResponse) response, oAuth2IdentityProvider)); + } else { + throw new UnsupportedOperationException(format("Unsupported IdentityProvider class: %s ", provider.getClass())); + } + } catch (NotAllowUserToSignUpException e) { + handleNotAllowedToSignUpError(e, (HttpServletResponse) response); + } catch (Exception e) { + handleError(e, (HttpServletResponse) response, String.format("Fail to initialize authentication with provider '%s'", keyProvider)); + } + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // Nothing to do + } + + @Override + public void destroy() { + // Nothing to do + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/NotAllowUserToSignUpException.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/NotAllowUserToSignUpException.java new file mode 100644 index 00000000000..02269964a45 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/NotAllowUserToSignUpException.java @@ -0,0 +1,35 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import org.sonar.api.server.authentication.IdentityProvider; + +public class NotAllowUserToSignUpException extends RuntimeException { + + private final IdentityProvider provider; + + public NotAllowUserToSignUpException(IdentityProvider provider) { + this.provider = provider; + } + + public IdentityProvider getProvider() { + return provider; + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2CallbackFilter.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2CallbackFilter.java new file mode 100644 index 00000000000..7240a936f7f --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2CallbackFilter.java @@ -0,0 +1,84 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.sonar.api.server.authentication.IdentityProvider; +import org.sonar.api.server.authentication.OAuth2IdentityProvider; +import org.sonar.api.web.ServletFilter; + +import static org.sonar.server.authentication.AuthenticationError.handleError; +import static org.sonar.server.authentication.AuthenticationError.handleNotAllowedToSignUpError; + +public class OAuth2CallbackFilter extends ServletFilter { + + public static final String CALLBACK_PATH = "/oauth2/callback"; + + private final IdentityProviderRepository identityProviderRepository; + private final OAuth2ContextFactory oAuth2ContextFactory; + + public OAuth2CallbackFilter(IdentityProviderRepository identityProviderRepository, OAuth2ContextFactory oAuth2ContextFactory) { + this.identityProviderRepository = identityProviderRepository; + this.oAuth2ContextFactory = oAuth2ContextFactory; + } + + @Override + public UrlPattern doGetPattern() { + return UrlPattern.create(CALLBACK_PATH + "/*"); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + String requestUri = httpRequest.getRequestURI(); + String keyProvider = requestUri.replace(CALLBACK_PATH + "/", ""); + + try { + IdentityProvider provider = identityProviderRepository.getEnabledByKey(keyProvider); + if (provider instanceof OAuth2IdentityProvider) { + OAuth2IdentityProvider oauthProvider = (OAuth2IdentityProvider) provider; + oauthProvider.callback(oAuth2ContextFactory.newCallback(httpRequest, (HttpServletResponse) response, oauthProvider)); + } else { + handleError((HttpServletResponse) response, String.format("Not an OAuth2IdentityProvider: %s", provider.getClass())); + } + } catch (NotAllowUserToSignUpException e) { + handleNotAllowedToSignUpError(e, (HttpServletResponse) response); + } catch (Exception e) { + handleError(e, (HttpServletResponse) response, String.format("Fail to callback authentication with %s", keyProvider)); + } + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // Nothing to do + } + + @Override + public void destroy() { + // Nothing to do + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2ContextFactory.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2ContextFactory.java new file mode 100644 index 00000000000..ce1d920becd --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/OAuth2ContextFactory.java @@ -0,0 +1,115 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import java.io.IOException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.sonar.api.platform.Server; +import org.sonar.api.server.authentication.OAuth2IdentityProvider; +import org.sonar.api.server.authentication.UserIdentity; + +import static org.sonar.api.CoreProperties.SERVER_BASE_URL; + +public class OAuth2ContextFactory { + + private final UserIdentityAuthenticator userIdentityAuthenticator; + private final Server server; + private final CsrfVerifier csrfVerifier; + + public OAuth2ContextFactory(UserIdentityAuthenticator userIdentityAuthenticator, Server server, CsrfVerifier csrfVerifier) { + this.userIdentityAuthenticator = userIdentityAuthenticator; + this.server = server; + this.csrfVerifier = csrfVerifier; + } + + public OAuth2IdentityProvider.InitContext newContext(HttpServletRequest request, HttpServletResponse response, OAuth2IdentityProvider identityProvider) { + return new OAuthContextImpl(request, response, identityProvider); + } + + public OAuth2IdentityProvider.CallbackContext newCallback(HttpServletRequest request, HttpServletResponse response, OAuth2IdentityProvider identityProvider) { + return new OAuthContextImpl(request, response, identityProvider); + } + + private class OAuthContextImpl implements OAuth2IdentityProvider.InitContext, OAuth2IdentityProvider.CallbackContext { + + private final HttpServletRequest request; + private final HttpServletResponse response; + private final OAuth2IdentityProvider identityProvider; + + public OAuthContextImpl(HttpServletRequest request, HttpServletResponse response, OAuth2IdentityProvider identityProvider) { + this.request = request; + this.response = response; + this.identityProvider = identityProvider; + } + + @Override + public String getCallbackUrl() { + String publicRootUrl = server.getPublicRootUrl(); + if (publicRootUrl.startsWith("http:") && !server.isDev()) { + throw new IllegalStateException(String.format("The server url should be configured in https, please update the property '%s'", SERVER_BASE_URL)); + } + return publicRootUrl + OAuth2CallbackFilter.CALLBACK_PATH + "/" + identityProvider.getKey(); + } + + @Override + public String generateCsrfState() { + return csrfVerifier.generateState(response); + } + + @Override + public HttpServletRequest getRequest() { + return request; + } + + @Override + public HttpServletResponse getResponse() { + return response; + } + + @Override + public void redirectTo(String url) { + try { + response.sendRedirect(url); + } catch (IOException e) { + throw new IllegalStateException(String.format("Fail to redirect to %s", url), e); + } + } + + @Override + public void verifyCsrfState() { + csrfVerifier.verifyState(request, response); + } + + @Override + public void redirectToRequestedPage() { + try { + getResponse().sendRedirect("/"); + } catch (IOException e) { + throw new IllegalStateException("Fail to redirect to home", e); + } + } + + @Override + public void authenticate(UserIdentity userIdentity) { + userIdentityAuthenticator.authenticate(userIdentity, identityProvider, request.getSession()); + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/UserIdentityAuthenticator.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/UserIdentityAuthenticator.java new file mode 100644 index 00000000000..803b3fc39a8 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/UserIdentityAuthenticator.java @@ -0,0 +1,81 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import com.google.common.base.Optional; +import javax.servlet.http.HttpSession; +import org.sonar.api.server.authentication.IdentityProvider; +import org.sonar.api.server.authentication.UserIdentity; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.UserDto; +import org.sonar.server.user.NewUser; +import org.sonar.server.user.UpdateUser; +import org.sonar.server.user.UserUpdater; + +public class UserIdentityAuthenticator { + + private final DbClient dbClient; + private final UserUpdater userUpdater; + private final UuidFactory uuidFactory; + + public UserIdentityAuthenticator(DbClient dbClient, UserUpdater userUpdater, UuidFactory uuidFactory) { + this.dbClient = dbClient; + this.userUpdater = userUpdater; + this.uuidFactory = uuidFactory; + } + + public void authenticate(UserIdentity user, IdentityProvider provider, HttpSession session) { + long userDbId = register(user, provider); + + // hack to disable Ruby on Rails authentication + session.setAttribute("user_id", userDbId); + } + + private long register(UserIdentity user, IdentityProvider provider) { + DbSession dbSession = dbClient.openSession(false); + try { + String userId = user.getId(); + Optional userDto = dbClient.userDao().selectByExternalIdentity(dbSession, userId, provider.getKey()); + if (userDto.isPresent() && userDto.get().isActive()) { + userUpdater.update(dbSession, UpdateUser.create(userDto.get().getLogin()) + .setEmail(user.getEmail()) + .setName(user.getName()) + ); + return userDto.get().getId(); + } + + if (!provider.allowsUsersToSignUp()) { + throw new NotAllowUserToSignUpException(provider); + } + userUpdater.create(dbSession, NewUser.create() + .setLogin(uuidFactory.create()) + .setEmail(user.getEmail()) + .setName(user.getName()) + .setExternalIdentity(new NewUser.ExternalIdentity(provider.getKey(), userId)) + ); + return dbClient.userDao().selectOrFailByExternalIdentity(dbSession, userId, provider.getKey()).getId(); + + } finally { + dbClient.closeSession(dbSession); + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/package-info.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/package-info.java new file mode 100644 index 00000000000..e62e32d0b94 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.authentication; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/ServerImpl.java b/server/sonar-server/src/main/java/org/sonar/server/platform/ServerImpl.java index 04ff6969808..7ab16e8d70d 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/platform/ServerImpl.java +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/ServerImpl.java @@ -21,7 +21,17 @@ package org.sonar.server.platform; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; +import com.google.common.base.Objects; import com.google.common.io.Resources; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Properties; +import javax.annotation.CheckForNull; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.picocontainer.Startable; @@ -32,16 +42,8 @@ import org.sonar.api.utils.log.Logger; import org.sonar.api.utils.log.Loggers; import org.sonar.process.ProcessProperties; -import javax.annotation.CheckForNull; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Properties; +import static org.sonar.api.CoreProperties.SERVER_BASE_URL; +import static org.sonar.api.CoreProperties.SERVER_BASE_URL_DEFAULT_VALUE; public final class ServerImpl extends Server implements Startable { private static final Logger LOG = Loggers.get(ServerImpl.class); @@ -139,6 +141,21 @@ public final class ServerImpl extends Server implements Startable { return contextPath; } + @Override + public String getPublicRootUrl() { + return get(SERVER_BASE_URL, SERVER_BASE_URL_DEFAULT_VALUE); + } + + @Override + public boolean isDev() { + return settings.getBoolean("sonar.web.dev"); + } + + @Override + public boolean isSecured() { + return get(SERVER_BASE_URL, SERVER_BASE_URL_DEFAULT_VALUE).startsWith("https://"); + } + private static String readVersion(String filename) throws IOException { URL url = ServerImpl.class.getResource(filename); if (url != null) { @@ -170,4 +187,9 @@ public final class ServerImpl extends Server implements Startable { public String getURL() { return null; } + + private String get(String key, String defaultValue) { + return Objects.firstNonNull(settings.getString(key), defaultValue); + } + } diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index 1e1d569e77f..89b2cfd82e0 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -48,7 +48,7 @@ import org.sonar.server.activity.index.ActivityIndexDefinition; import org.sonar.server.activity.index.ActivityIndexer; import org.sonar.server.activity.ws.ActivitiesWs; import org.sonar.server.activity.ws.ActivityMapping; -import org.sonar.server.authentication.ws.AuthenticationWs; +import org.sonar.server.authentication.AuthenticationModule; import org.sonar.server.batch.BatchWsModule; import org.sonar.server.charts.ChartFactory; import org.sonar.server.charts.DistributionAreaChart; @@ -521,7 +521,7 @@ public class PlatformLevel4 extends PlatformLevel { L10nWs.class, // authentication - AuthenticationWs.class, + AuthenticationModule.class, // users SecurityRealmFactory.class, diff --git a/server/sonar-server/src/main/java/org/sonar/server/ui/JRubyFacade.java b/server/sonar-server/src/main/java/org/sonar/server/ui/JRubyFacade.java index 402900b4083..1ac5edd05ec 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/ui/JRubyFacade.java +++ b/server/sonar-server/src/main/java/org/sonar/server/ui/JRubyFacade.java @@ -49,6 +49,8 @@ import org.sonar.db.component.ResourceIndexDao; import org.sonar.db.version.DatabaseMigration; import org.sonar.db.version.DatabaseVersion; import org.sonar.process.ProcessProperties; +import org.sonar.server.authentication.IdentityProviderRepository; +import org.sonar.api.server.authentication.IdentityProvider; import org.sonar.server.component.ComponentCleanerService; import org.sonar.server.db.migrations.DatabaseMigrator; import org.sonar.server.measure.MeasureFilterEngine; @@ -411,4 +413,8 @@ public final class JRubyFacade { get(ResourceIndexDao.class).indexResource(resourceId); } + public List getIdentityProviders(){ + return get(IdentityProviderRepository.class).getAllEnabledAndSorted(); + } + } diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java b/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java index 2003643daee..9fd0e44b238 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java +++ b/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java @@ -133,20 +133,24 @@ public class UserUpdater { public void update(UpdateUser updateUser) { DbSession dbSession = dbClient.openSession(false); try { - UserDto user = dbClient.userDao().selectByLogin(dbSession, updateUser.login()); - if (user == null) { - throw new NotFoundException(String.format("User with login '%s' has not been found", updateUser.login())); - } - updateUserDto(dbSession, updateUser, user); - updateUser(dbSession, user); - dbSession.commit(); - notifyNewUser(user.getLogin(), user.getName(), user.getEmail()); - userIndexer.index(); + update(dbSession, updateUser); } finally { dbClient.closeSession(dbSession); } } + public void update(DbSession dbSession, UpdateUser updateUser) { + UserDto user = dbClient.userDao().selectByLogin(dbSession, updateUser.login()); + if (user == null) { + throw new NotFoundException(String.format("User with login '%s' has not been found", updateUser.login())); + } + updateUserDto(dbSession, updateUser, user); + updateUser(dbSession, user); + dbSession.commit(); + notifyNewUser(user.getLogin(), user.getName(), user.getEmail()); + userIndexer.index(); + } + public void deactivateUserByLogin(String login) { DbSession dbSession = dbClient.openSession(false); try { @@ -297,7 +301,7 @@ public class UserUpdater { } private void validateScmAccounts(DbSession dbSession, List scmAccounts, @Nullable String login, @Nullable String email, @Nullable UserDto existingUser, - List messages) { + List messages) { for (String scmAccount : scmAccounts) { if (scmAccount.equals(login) || scmAccount.equals(email)) { messages.add(Message.of("user.login_or_email_used_as_scm_account")); diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/BaseContextFactoryTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/BaseContextFactoryTest.java new file mode 100644 index 00000000000..08ca98fa224 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/BaseContextFactoryTest.java @@ -0,0 +1,78 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import org.junit.Before; +import org.junit.Test; +import org.sonar.api.platform.Server; +import org.sonar.api.server.authentication.BaseIdentityProvider; +import org.sonar.api.server.authentication.UserIdentity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BaseContextFactoryTest { + + static String PUBLIC_ROOT_URL = "https://mydomain.com"; + + static UserIdentity USER_IDENTITY = UserIdentity.builder() + .setId("johndoo") + .setName("John") + .setEmail("john@email.com") + .build(); + + UserIdentityAuthenticator userIdentityAuthenticator = mock(UserIdentityAuthenticator.class); + Server server = mock(Server.class); + + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + BaseIdentityProvider identityProvider = mock(BaseIdentityProvider.class); + + BaseContextFactory underTest = new BaseContextFactory(userIdentityAuthenticator, server); + + @Before + public void setUp() throws Exception { + when(server.getPublicRootUrl()).thenReturn(PUBLIC_ROOT_URL); + } + + @Test + public void create_context() throws Exception { + BaseIdentityProvider.Context context = underTest.newContext(request, response, identityProvider); + + assertThat(context.getRequest()).isEqualTo(request); + assertThat(context.getResponse()).isEqualTo(response); + assertThat(context.getServerBaseURL()).isEqualTo(PUBLIC_ROOT_URL); + } + + @Test + public void authenticate() throws Exception { + BaseIdentityProvider.Context context = underTest.newContext(request, response, identityProvider); + HttpSession session = mock(HttpSession.class); + when(request.getSession()).thenReturn(session); + + context.authenticate(USER_IDENTITY); + verify(userIdentityAuthenticator).authenticate(USER_IDENTITY, identityProvider, session); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/CsrfVerifierTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/CsrfVerifierTest.java new file mode 100644 index 00000000000..918e0e9f27b --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/CsrfVerifierTest.java @@ -0,0 +1,127 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; +import org.sonar.api.platform.Server; +import org.sonar.server.exceptions.UnauthorizedException; + +import static org.apache.commons.codec.digest.DigestUtils.sha1Hex; +import static org.apache.commons.codec.digest.DigestUtils.sha256Hex; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CsrfVerifierTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + ArgumentCaptor cookieArgumentCaptor = ArgumentCaptor.forClass(Cookie.class); + + Server server = mock(Server.class); + HttpServletResponse response = mock(HttpServletResponse.class); + HttpServletRequest request = mock(HttpServletRequest.class); + + CsrfVerifier underTest = new CsrfVerifier(server); + + @Test + public void generate_state_on_secured_server() throws Exception { + when(server.isSecured()).thenReturn(true); + + String state = underTest.generateState(response); + assertThat(state).isNotEmpty(); + + verify(response).addCookie(cookieArgumentCaptor.capture()); + + verifyCookie(cookieArgumentCaptor.getValue(), true); + } + + @Test + public void generate_state_on_not_secured_server() throws Exception { + when(server.isSecured()).thenReturn(false); + + String state = underTest.generateState(response); + assertThat(state).isNotEmpty(); + + verify(response).addCookie(cookieArgumentCaptor.capture()); + + verifyCookie(cookieArgumentCaptor.getValue(), false); + } + + @Test + public void verify_state() throws Exception { + String state = "state"; + when(request.getCookies()).thenReturn(new Cookie[] {new Cookie("OAUTHSTATE", sha256Hex(state))}); + when(request.getParameter("state")).thenReturn(state); + + underTest.verifyState(request, response); + + verify(response).addCookie(cookieArgumentCaptor.capture()); + Cookie updatedCookie = cookieArgumentCaptor.getValue(); + assertThat(updatedCookie.getName()).isEqualTo("OAUTHSTATE"); + assertThat(updatedCookie.getValue()).isNull(); + assertThat(updatedCookie.getPath()).isEqualTo("/"); + assertThat(updatedCookie.getMaxAge()).isEqualTo(0); + } + + @Test + public void fail_with_unauthorized_when_state_cookie_is_not_the_same_as_state_parameter() throws Exception { + when(request.getCookies()).thenReturn(new Cookie[] {new Cookie("OAUTHSTATE", sha1Hex("state"))}); + when(request.getParameter("state")).thenReturn("other value"); + + thrown.expect(UnauthorizedException.class); + underTest.verifyState(request, response); + } + + @Test + public void fail_to_verify_state_when_state_cookie_is_null() throws Exception { + when(request.getCookies()).thenReturn(new Cookie[] {new Cookie("OAUTHSTATE", null)}); + when(request.getParameter("state")).thenReturn("state"); + + thrown.expect(UnauthorizedException.class); + underTest.verifyState(request, response); + } + + @Test + public void fail_with_unauthorized_when_state_parameter_is_empty() throws Exception { + when(request.getCookies()).thenReturn(new Cookie[] {new Cookie("OAUTHSTATE", sha1Hex("state"))}); + when(request.getParameter("state")).thenReturn(""); + + thrown.expect(UnauthorizedException.class); + underTest.verifyState(request, response); + } + + private void verifyCookie(Cookie cookie, boolean isSecured) { + assertThat(cookie.getName()).isEqualTo("OAUTHSTATE"); + assertThat(cookie.getValue()).isNotEmpty(); + assertThat(cookie.getPath()).isEqualTo("/"); + assertThat(cookie.isHttpOnly()).isTrue(); + assertThat(cookie.getMaxAge()).isEqualTo(-1); + assertThat(cookie.getSecure()).isEqualTo(isSecured); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/FakeBasicIdentityProvider.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/FakeBasicIdentityProvider.java new file mode 100644 index 00000000000..a0ab806fb10 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/FakeBasicIdentityProvider.java @@ -0,0 +1,42 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import org.sonar.api.server.authentication.BaseIdentityProvider; + +class FakeBasicIdentityProvider extends TestIdentityProvider implements BaseIdentityProvider { + + private boolean initCalled = false; + + public FakeBasicIdentityProvider(String key, boolean enabled) { + setKey(key); + setEnabled(enabled); + } + + @Override + public void init(Context context) { + initCalled = true; + } + + public boolean isInitCalled() { + return initCalled; + } + +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/FakeOAuth2IdentityProvider.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/FakeOAuth2IdentityProvider.java new file mode 100644 index 00000000000..b174a2988f9 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/FakeOAuth2IdentityProvider.java @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import org.sonar.api.server.authentication.OAuth2IdentityProvider; + +class FakeOAuth2IdentityProvider extends TestIdentityProvider implements OAuth2IdentityProvider { + + private boolean initCalled = false; + private boolean callbackCalled = false; + + public FakeOAuth2IdentityProvider(String key, boolean enabled) { + setKey(key); + setEnabled(enabled); + } + + @Override + public void init(InitContext context) { + initCalled = true; + } + + @Override + public void callback(CallbackContext context) { + callbackCalled = true; + } + + public boolean isInitCalled() { + return initCalled; + } + + public boolean isCallbackCalled() { + return callbackCalled; + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryRule.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryRule.java new file mode 100644 index 00000000000..40d1897f3f1 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryRule.java @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.sonar.api.server.authentication.IdentityProvider; + +public class IdentityProviderRepositoryRule extends IdentityProviderRepository implements TestRule { + + public IdentityProviderRepositoryRule addIdentityProvider(IdentityProvider identityProvider) { + providersByKey.put(identityProvider.getKey(), identityProvider); + return this; + } + + @Override + public Statement apply(final Statement statement, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + try { + statement.evaluate(); + } finally { + providersByKey.clear(); + } + } + }; + } + +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryTest.java new file mode 100644 index 00000000000..a82d0306a8b --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/IdentityProviderRepositoryTest.java @@ -0,0 +1,90 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.sonar.api.server.authentication.IdentityProvider; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; + +public class IdentityProviderRepositoryTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + static IdentityProvider GITHUB = new TestIdentityProvider() + .setKey("github") + .setName("Github") + .setEnabled(true); + + static IdentityProvider BITBUCKET = new TestIdentityProvider() + .setKey("bitbucket") + .setName("Bitbucket") + .setEnabled(true); + + static IdentityProvider DISABLED = new TestIdentityProvider() + .setKey("disabled") + .setName("Disabled") + .setEnabled(false); + + @Test + public void return_enabled_provider() throws Exception { + IdentityProviderRepository underTest = new IdentityProviderRepository(asList(GITHUB, BITBUCKET, DISABLED)); + + assertThat(underTest.getEnabledByKey(GITHUB.getKey())).isEqualTo(GITHUB); + assertThat(underTest.getEnabledByKey(BITBUCKET.getKey())).isEqualTo(BITBUCKET); + } + + @Test + public void fail_on_disabled_provider() throws Exception { + IdentityProviderRepository underTest = new IdentityProviderRepository(asList(GITHUB, BITBUCKET, DISABLED)); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Identity provider disabled does not exist or is not enabled"); + underTest.getEnabledByKey(DISABLED.getKey()); + } + + @Test + public void return_all_enabled_providers() throws Exception { + IdentityProviderRepository underTest = new IdentityProviderRepository(asList(GITHUB, BITBUCKET, DISABLED)); + + List providers = underTest.getAllEnabledAndSorted(); + assertThat(providers).containsOnly(GITHUB, BITBUCKET); + } + + @Test + public void return_sorted_enabled_providers() throws Exception { + IdentityProviderRepository underTest = new IdentityProviderRepository(asList(GITHUB, BITBUCKET)); + + List providers = underTest.getAllEnabledAndSorted(); + assertThat(providers).containsExactly(BITBUCKET, GITHUB); + } + + @Test + public void return_nothing_when_no_identity_provider() throws Exception { + IdentityProviderRepository underTest = new IdentityProviderRepository(); + + assertThat(underTest.getAllEnabledAndSorted()).isEmpty(); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/InitFilterTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/InitFilterTest.java new file mode 100644 index 00000000000..04e3c74edce --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/InitFilterTest.java @@ -0,0 +1,160 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.sonar.api.server.authentication.BaseIdentityProvider; +import org.sonar.api.server.authentication.IdentityProvider; +import org.sonar.api.server.authentication.OAuth2IdentityProvider; +import org.sonar.api.utils.log.LogTester; +import org.sonar.api.utils.log.LoggerLevel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class InitFilterTest { + + static String OAUTH2_PROVIDER_KEY = "github"; + static String BASIC_PROVIDER_KEY = "openid"; + + @Rule + public LogTester logTester = new LogTester(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public IdentityProviderRepositoryRule identityProviderRepository = new IdentityProviderRepositoryRule(); + + BaseContextFactory baseContextFactory = mock(BaseContextFactory.class); + OAuth2ContextFactory oAuth2ContextFactory = mock(OAuth2ContextFactory.class); + + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + FakeOAuth2IdentityProvider oAuth2IdentityProvider = new FakeOAuth2IdentityProvider(OAUTH2_PROVIDER_KEY, true); + OAuth2IdentityProvider.InitContext oauth2Context = mock(OAuth2IdentityProvider.InitContext.class); + + FakeBasicIdentityProvider baseIdentityProvider = new FakeBasicIdentityProvider(BASIC_PROVIDER_KEY, true); + BaseIdentityProvider.Context baseContext = mock(BaseIdentityProvider.Context.class); + + InitFilter underTest = new InitFilter(identityProviderRepository, baseContextFactory, oAuth2ContextFactory); + + @Before + public void setUp() throws Exception { + when(oAuth2ContextFactory.newContext(request, response, oAuth2IdentityProvider)).thenReturn(oauth2Context); + when(baseContextFactory.newContext(request, response, baseIdentityProvider)).thenReturn(baseContext); + } + + @Test + public void do_get_pattern() throws Exception { + assertThat(underTest.doGetPattern()).isNotNull(); + } + + @Test + public void do_filter_on_auth2_identity_provider() throws Exception { + when(request.getRequestURI()).thenReturn("/sessions/init/" + OAUTH2_PROVIDER_KEY); + identityProviderRepository.addIdentityProvider(oAuth2IdentityProvider); + + underTest.doFilter(request, response, chain); + + assertOAuth2InitCalled(); + } + + @Test + public void do_filter_on_basic_identity_provider() throws Exception { + when(request.getRequestURI()).thenReturn("/sessions/init/" + BASIC_PROVIDER_KEY); + identityProviderRepository.addIdentityProvider(baseIdentityProvider); + + underTest.doFilter(request, response, chain); + + assertBasicInitCalled(); + } + + @Test + public void fail_if_identity_provider_key_is_empty() throws Exception { + when(request.getRequestURI()).thenReturn("/sessions/init/"); + + underTest.doFilter(request, response, chain); + + assertError("Fail to initialize authentication with provider ''"); + } + + @Test + public void fail_if_identity_provider_class_is_unsuported() throws Exception { + final String unsupportedKey = "unsupported"; + when(request.getRequestURI()).thenReturn("/sessions/init/" + unsupportedKey); + identityProviderRepository.addIdentityProvider(new IdentityProvider() { + @Override + public String getKey() { + return unsupportedKey; + } + + @Override + public String getName() { + return null; + } + + @Override + public String getIconPath() { + return null; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean allowsUsersToSignUp() { + return false; + } + }); + + underTest.doFilter(request, response, chain); + + assertError("Fail to initialize authentication with provider 'unsupported'"); + } + + private void assertOAuth2InitCalled(){ + assertThat(logTester.logs(LoggerLevel.ERROR)).isEmpty(); + assertThat(oAuth2IdentityProvider.isInitCalled()).isTrue(); + } + + private void assertBasicInitCalled(){ + assertThat(logTester.logs(LoggerLevel.ERROR)).isEmpty(); + assertThat(baseIdentityProvider.isInitCalled()).isTrue(); + } + + private void assertError(String expectedError) throws Exception { + assertThat(logTester.logs(LoggerLevel.ERROR)).contains(expectedError); + verify(response).sendRedirect("/sessions/unauthorized"); + assertThat(oAuth2IdentityProvider.isInitCalled()).isFalse(); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2CallbackFilterTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2CallbackFilterTest.java new file mode 100644 index 00000000000..e8e6c765916 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2CallbackFilterTest.java @@ -0,0 +1,114 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.sonar.api.server.authentication.OAuth2IdentityProvider; +import org.sonar.api.utils.log.LogTester; +import org.sonar.api.utils.log.LoggerLevel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class OAuth2CallbackFilterTest { + + static String OAUTH2_PROVIDER_KEY = "github"; + + @Rule + public LogTester logTester = new LogTester(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public IdentityProviderRepositoryRule identityProviderRepository = new IdentityProviderRepositoryRule(); + + OAuth2ContextFactory oAuth2ContextFactory = mock(OAuth2ContextFactory.class); + + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + FakeOAuth2IdentityProvider oAuth2IdentityProvider = new FakeOAuth2IdentityProvider(OAUTH2_PROVIDER_KEY, true); + OAuth2IdentityProvider.InitContext oauth2Context = mock(OAuth2IdentityProvider.InitContext.class); + + OAuth2CallbackFilter underTest = new OAuth2CallbackFilter(identityProviderRepository, oAuth2ContextFactory); + + @Before + public void setUp() throws Exception { + when(oAuth2ContextFactory.newContext(request, response, oAuth2IdentityProvider)).thenReturn(oauth2Context); + } + + @Test + public void do_get_pattern() throws Exception { + assertThat(underTest.doGetPattern()).isNotNull(); + } + + @Test + public void do_filter_on_auth2_identity_provider() throws Exception { + when(request.getRequestURI()).thenReturn("/oauth2/callback/" + OAUTH2_PROVIDER_KEY); + identityProviderRepository.addIdentityProvider(oAuth2IdentityProvider); + + underTest.doFilter(request, response, chain); + + assertCallbackCalled(); + } + + @Test + public void fail_on_not_oauth2_provider() throws Exception { + String providerKey = "openid"; + when(request.getRequestURI()).thenReturn("/oauth2/callback/" + providerKey); + identityProviderRepository.addIdentityProvider(new FakeBasicIdentityProvider(providerKey, true)); + + underTest.doFilter(request, response, chain); + + assertError("Not an OAuth2IdentityProvider: class org.sonar.server.authentication.FakeBasicIdentityProvider"); + } + + @Test + public void fail_on_disabled_provider() throws Exception { + when(request.getRequestURI()).thenReturn("/oauth2/callback/" + OAUTH2_PROVIDER_KEY); + identityProviderRepository.addIdentityProvider(new FakeOAuth2IdentityProvider(OAUTH2_PROVIDER_KEY, false)); + + underTest.doFilter(request, response, chain); + + assertError("Fail to callback authentication with github"); + } + + private void assertCallbackCalled(){ + assertThat(logTester.logs(LoggerLevel.ERROR)).isEmpty(); + assertThat(oAuth2IdentityProvider.isCallbackCalled()).isTrue(); + } + + private void assertError(String expectedError) throws Exception { + assertThat(logTester.logs(LoggerLevel.ERROR)).contains(expectedError); + verify(response).sendRedirect("/sessions/unauthorized"); + assertThat(oAuth2IdentityProvider.isInitCalled()).isFalse(); + } + +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2ContextFactoryTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2ContextFactoryTest.java new file mode 100644 index 00000000000..bb291b0667a --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/OAuth2ContextFactoryTest.java @@ -0,0 +1,148 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.sonar.api.platform.Server; +import org.sonar.api.server.authentication.OAuth2IdentityProvider; +import org.sonar.api.server.authentication.UserIdentity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class OAuth2ContextFactoryTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + static String PROVIDER_KEY = "github"; + + static String SECURED_PUBLIC_ROOT_URL = "https://mydomain.com"; + static String NOT_SECURED_PUBLIC_URL = "http://mydomain.com"; + + static UserIdentity USER_IDENTITY = UserIdentity.builder() + .setId("johndoo") + .setName("John") + .setEmail("john@email.com") + .build(); + + UserIdentityAuthenticator userIdentityAuthenticator = mock(UserIdentityAuthenticator.class); + Server server = mock(Server.class); + CsrfVerifier csrfVerifier = mock(CsrfVerifier.class); + + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + HttpSession session = mock(HttpSession.class); + OAuth2IdentityProvider identityProvider = mock(OAuth2IdentityProvider.class); + + OAuth2ContextFactory underTest = new OAuth2ContextFactory(userIdentityAuthenticator, server, csrfVerifier); + + @Before + public void setUp() throws Exception { + when(request.getSession()).thenReturn(session); + when(identityProvider.getKey()).thenReturn(PROVIDER_KEY); + } + + @Test + public void create_context() throws Exception { + when(server.getPublicRootUrl()).thenReturn(SECURED_PUBLIC_ROOT_URL); + + OAuth2IdentityProvider.InitContext context = underTest.newContext(request, response, identityProvider); + + assertThat(context.getRequest()).isEqualTo(request); + assertThat(context.getResponse()).isEqualTo(response); + assertThat(context.getCallbackUrl()).isEqualTo("https://mydomain.com/oauth2/callback/github"); + } + + @Test + public void generate_csrf_state() throws Exception { + OAuth2IdentityProvider.InitContext context = underTest.newContext(request, response, identityProvider); + + context.generateCsrfState(); + + verify(csrfVerifier).generateState(response); + } + + @Test + public void redirect_to() throws Exception { + OAuth2IdentityProvider.InitContext context = underTest.newContext(request, response, identityProvider); + + context.redirectTo("/test"); + + verify(response).sendRedirect("/test"); + } + + @Test + public void fail_to_get_callback_url_on_not_secured_server() throws Exception { + when(server.getPublicRootUrl()).thenReturn(NOT_SECURED_PUBLIC_URL); + + OAuth2IdentityProvider.InitContext context = underTest.newContext(request, response, identityProvider); + + thrown.expect(IllegalStateException.class); + thrown.expectMessage("The server url should be configured in https, please update the property 'sonar.core.serverBaseURL'"); + context.getCallbackUrl(); + } + + @Test + public void create_callback() throws Exception { + when(server.getPublicRootUrl()).thenReturn(SECURED_PUBLIC_ROOT_URL); + + OAuth2IdentityProvider.CallbackContext callback = underTest.newCallback(request, response, identityProvider); + + assertThat(callback.getRequest()).isEqualTo(request); + assertThat(callback.getResponse()).isEqualTo(response); + assertThat(callback.getCallbackUrl()).isEqualTo("https://mydomain.com/oauth2/callback/github"); + } + + @Test + public void authenticate() throws Exception { + OAuth2IdentityProvider.CallbackContext callback = underTest.newCallback(request, response, identityProvider); + + callback.authenticate(USER_IDENTITY); + + verify(userIdentityAuthenticator).authenticate(USER_IDENTITY, identityProvider, session); + } + + @Test + public void redirect_to_requested_page() throws Exception { + OAuth2IdentityProvider.CallbackContext callback = underTest.newCallback(request, response, identityProvider); + + callback.redirectToRequestedPage(); + + verify(response).sendRedirect("/"); + } + + @Test + public void verify_csrf_state() throws Exception { + OAuth2IdentityProvider.CallbackContext callback = underTest.newCallback(request, response, identityProvider); + + callback.verifyCsrfState(); + + verify(csrfVerifier).verifyState(request, response); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/TestIdentityProvider.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/TestIdentityProvider.java new file mode 100644 index 00000000000..60d4622f1fa --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/TestIdentityProvider.java @@ -0,0 +1,99 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import org.sonar.api.server.authentication.IdentityProvider; + +public class TestIdentityProvider implements IdentityProvider { + + private String key; + private String name; + private String iconPatch; + private boolean enabled; + private boolean allowsUsersToSignUp; + + @Override + public String getKey() { + return key; + } + + public TestIdentityProvider setKey(String key) { + this.key = key; + return this; + } + + @Override + public String getName() { + return name; + } + + public TestIdentityProvider setName(String name) { + this.name = name; + return this; + } + + @Override + public String getIconPath() { + return iconPatch; + } + + public TestIdentityProvider setIconPatch(String iconPatch) { + this.iconPatch = iconPatch; + return this; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + public TestIdentityProvider setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + @Override + public boolean allowsUsersToSignUp() { + return allowsUsersToSignUp; + } + + public TestIdentityProvider setAllowsUsersToSignUp(boolean allowsUsersToSignUp) { + this.allowsUsersToSignUp = allowsUsersToSignUp; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (getClass() != o.getClass()) { + return false; + } + + TestIdentityProvider that = (TestIdentityProvider) o; + return key.equals(that.key); + } + + @Override + public int hashCode() { + return key.hashCode(); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/UserIdentityAuthenticatorTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/UserIdentityAuthenticatorTest.java new file mode 100644 index 00000000000..649ba0a35c5 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/UserIdentityAuthenticatorTest.java @@ -0,0 +1,149 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.authentication; + +import com.google.common.base.Optional; +import javax.servlet.http.HttpSession; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; +import org.sonar.api.server.authentication.UserIdentity; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.UserDao; +import org.sonar.db.user.UserDto; +import org.sonar.server.user.NewUser; +import org.sonar.server.user.UpdateUser; +import org.sonar.server.user.UserUpdater; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UserIdentityAuthenticatorTest { + + static String USER_LOGIN = "ABCD"; + static UserDto ACTIVE_USER = new UserDto().setId(10L).setLogin(USER_LOGIN).setActive(true); + static UserDto UNACTIVE_USER = new UserDto().setId(11L).setLogin("UNACTIVE").setActive(false); + + static UserIdentity USER_IDENTITY = UserIdentity.builder() + .setId("johndoo") + .setName("John") + .setEmail("john@email.com") + .build(); + + static TestIdentityProvider IDENTITY_PROVIDER = new TestIdentityProvider() + .setKey("github") + .setEnabled(true) + .setAllowsUsersToSignUp(true); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + DbClient dbClient = mock(DbClient.class); + DbSession dbSession = mock(DbSession.class); + UserDao userDao = mock(UserDao.class); + + HttpSession httpSession = mock(HttpSession.class); + UserUpdater userUpdater = mock(UserUpdater.class); + UuidFactory uuidFactory = mock(UuidFactory.class); + + UserIdentityAuthenticator underTest = new UserIdentityAuthenticator(dbClient, userUpdater, uuidFactory); + + @Before + public void setUp() throws Exception { + when(dbClient.openSession(false)).thenReturn(dbSession); + when(dbClient.userDao()).thenReturn(userDao); + when(uuidFactory.create()).thenReturn(USER_LOGIN); + } + + @Test + public void authenticate_new_user() throws Exception { + when(userDao.selectByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(Optional.absent()); + when(userDao.selectOrFailByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(ACTIVE_USER); + + underTest.authenticate(USER_IDENTITY, IDENTITY_PROVIDER, httpSession); + + ArgumentCaptor newUserArgumentCaptor = ArgumentCaptor.forClass(NewUser.class); + verify(userUpdater).create(eq(dbSession), newUserArgumentCaptor.capture()); + NewUser newUser = newUserArgumentCaptor.getValue(); + + assertThat(newUser.login()).isEqualTo(USER_LOGIN); + assertThat(newUser.name()).isEqualTo("John"); + assertThat(newUser.email()).isEqualTo("john@email.com"); + assertThat(newUser.externalIdentity().getProvider()).isEqualTo("github"); + assertThat(newUser.externalIdentity().getId()).isEqualTo("johndoo"); + } + + @Test + public void authenticate_existing_user() throws Exception { + when(userDao.selectByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(Optional.of(ACTIVE_USER)); + + underTest.authenticate(USER_IDENTITY, IDENTITY_PROVIDER, httpSession); + + ArgumentCaptor updateUserArgumentCaptor = ArgumentCaptor.forClass(UpdateUser.class); + verify(userUpdater).update(eq(dbSession), updateUserArgumentCaptor.capture()); + UpdateUser newUser = updateUserArgumentCaptor.getValue(); + + assertThat(newUser.login()).isEqualTo(USER_LOGIN); + assertThat(newUser.name()).isEqualTo("John"); + assertThat(newUser.email()).isEqualTo("john@email.com"); + } + + @Test + public void authenticate_existing_disabled_user() throws Exception { + when(userDao.selectByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(Optional.of(UNACTIVE_USER)); + when(userDao.selectOrFailByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(UNACTIVE_USER); + + underTest.authenticate(USER_IDENTITY, IDENTITY_PROVIDER, httpSession); + + ArgumentCaptor newUserArgumentCaptor = ArgumentCaptor.forClass(NewUser.class); + verify(userUpdater).create(eq(dbSession), newUserArgumentCaptor.capture()); + } + + @Test + public void update_session_for_rails() throws Exception { + when(userDao.selectByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(Optional.of(ACTIVE_USER)); + + underTest.authenticate(USER_IDENTITY, IDENTITY_PROVIDER, httpSession); + + verify(httpSession).setAttribute("user_id", ACTIVE_USER.getId()); + } + + @Test + public void fail_to_authenticate_new_user_when_allow_users_to_signup_is_false() throws Exception { + when(userDao.selectByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(Optional.absent()); + when(userDao.selectOrFailByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(ACTIVE_USER); + + TestIdentityProvider identityProvider = new TestIdentityProvider() + .setKey("github") + .setName("Github") + .setEnabled(true) + .setAllowsUsersToSignUp(false); + + thrown.expect(NotAllowUserToSignUpException.class); + underTest.authenticate(USER_IDENTITY, identityProvider, httpSession); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/platform/ServerImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/platform/ServerImplTest.java index 0999df58358..6dd519dfe40 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/platform/ServerImplTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/platform/ServerImplTest.java @@ -19,6 +19,7 @@ */ package org.sonar.server.platform; +import java.io.File; import org.hamcrest.core.Is; import org.junit.Before; import org.junit.Rule; @@ -29,8 +30,6 @@ import org.sonar.api.CoreProperties; import org.sonar.api.config.Settings; import org.sonar.process.ProcessProperties; -import java.io.File; - import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertThat; @@ -139,4 +138,37 @@ public class ServerImplTest { assertThat(server.getContextPath()).isEqualTo("/my_path"); } + @Test + public void is_dev() throws Exception { + settings.setProperty("sonar.web.dev", true); + server.start(); + assertThat(server.isDev()).isTrue(); + } + + @Test + public void get_default_public_root_url() throws Exception { + server.start(); + assertThat(server.getPublicRootUrl()).isEqualTo("http://localhost:9000"); + } + + @Test + public void get_public_root_url() throws Exception { + settings.setProperty("sonar.core.serverBaseURL", "http://mydomain.com"); + server.start(); + assertThat(server.getPublicRootUrl()).isEqualTo("http://mydomain.com"); + } + + @Test + public void is_secured_on_secured_server() throws Exception { + settings.setProperty("sonar.core.serverBaseURL", "https://mydomain.com"); + server.start(); + assertThat(server.isSecured()).isTrue(); + } + + @Test + public void is_secured_on_not_secured_server() throws Exception { + settings.setProperty("sonar.core.serverBaseURL", "http://mydomain.com"); + server.start(); + assertThat(server.isSecured()).isFalse(); + } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/platform/ServerLifecycleNotifierTest.java b/server/sonar-server/src/test/java/org/sonar/server/platform/ServerLifecycleNotifierTest.java index bb83fa8d1bd..d91368da8b4 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/platform/ServerLifecycleNotifierTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/platform/ServerLifecycleNotifierTest.java @@ -19,17 +19,15 @@ */ package org.sonar.server.platform; +import java.io.File; +import java.util.Date; +import javax.annotation.CheckForNull; import org.junit.Before; import org.junit.Test; import org.sonar.api.platform.Server; import org.sonar.api.platform.ServerStartHandler; import org.sonar.api.platform.ServerStopHandler; -import javax.annotation.CheckForNull; - -import java.io.File; -import java.util.Date; - import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -120,6 +118,21 @@ class FakeServer extends Server { return null; } + @Override + public String getPublicRootUrl() { + return null; + } + + @Override + public boolean isDev() { + return false; + } + + @Override + public boolean isSecured() { + return false; + } + @Override public String getURL() { return null; diff --git a/server/sonar-server/src/test/java/org/sonar/server/platform/ws/StatusActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/platform/ws/StatusActionTest.java index 27edee62edd..74ef58d2ad4 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/platform/ws/StatusActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/platform/ws/StatusActionTest.java @@ -230,6 +230,21 @@ public class StatusActionTest { throw new UnsupportedOperationException(); } + @Override + public String getPublicRootUrl() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isDev() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isSecured() { + throw new UnsupportedOperationException(); + } + @Override public String getURL() { throw new UnsupportedOperationException(); diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb index 5385d577fb1..535c53b7a4d 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb @@ -70,4 +70,11 @@ class SessionsController < ApplicationController end end + def unauthorized + flash[:error] = session['error'] + session['error'] = nil + params[:layout]='false' + render :action => 'unauthorized' + end + end diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/_form.html.erb b/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/_form.html.erb index 2d40f8d5a1e..21231c69b9b 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/_form.html.erb +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/_form.html.erb @@ -33,10 +33,28 @@

-

- - <%= message('cancel') -%> -

+
+
+ <% auth_providers = Api::Utils.java_facade.getIdentityProviders().to_a %> +
    + <% auth_providers.each do |provider| %> +
  • + + <%= provider.getName().to_s -%> + +
  • + <% end %> +
+
+
+ + <%= message('cancel') -%> +
+
+