diff options
45 files changed, 2640 insertions, 58 deletions
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 @@ -919,7 +919,6 @@ <scope>test</scope> </dependency> - <!-- tomcat --> <dependency> <groupId>org.apache.tomcat.embed</groupId> 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 @@ <groupId>${project.groupId}</groupId> <artifactId>sonar-dev-cockpit-bridge</artifactId> </dependency> + <!-- unit tests --> <dependency> <groupId>${project.groupId}</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<String, IdentityProvider> providersByKey = new HashMap<>(); + + public IdentityProviderRepository(List<IdentityProvider> 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<IdentityProvider> getAllEnabledAndSorted() { + return from(providersByKey.values()) + .filter(IsEnabledFilter.INSTANCE) + .toSortedList( + Ordering.natural().onResultOf(ToName.INSTANCE) + ); + } + + private enum IsEnabledFilter implements Predicate<IdentityProvider> { + INSTANCE; + + @Override + public boolean apply(@Nonnull IdentityProvider input) { + return input.isEnabled(); + } + } + + private enum ToKey implements Function<IdentityProvider, String> { + INSTANCE; + + @Override + public String apply(@Nonnull IdentityProvider input) { + return input.getKey(); + } + } + + private enum ToName implements Function<IdentityProvider, String> { + 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> 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<IdentityProvider> 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<String> scmAccounts, @Nullable String login, @Nullable String email, @Nullable UserDto existingUser, - List<Message> messages) { + List<Message> 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<Cookie> 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<IdentityProvider> 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<IdentityProvider> 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.<UserDto>absent()); + when(userDao.selectOrFailByExternalIdentity(dbSession, USER_IDENTITY.getId(), IDENTITY_PROVIDER.getKey())).thenReturn(ACTIVE_USER); + + underTest.authenticate(USER_IDENTITY, IDENTITY_PROVIDER, httpSession); + + ArgumentCaptor<NewUser> 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<UpdateUser> 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<NewUser> 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.<UserDto>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; @@ -121,6 +119,21 @@ class FakeServer extends Server { } @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 @@ -231,6 +231,21 @@ public class StatusActionTest { } @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 @@ <label for="remember_me"><%= message('sessions.remember_me') -%></label> </p> - <p class="text-right"> - <button name="commit"><%= message('sessions.log_in') -%></button> - <a class="spacer-left" href="<%= home_path -%>"><%= message('cancel') -%></a> - </p> + <div> + <div class="pull-left"> + <% auth_providers = Api::Utils.java_facade.getIdentityProviders().to_a %> + <ul class="list-inline"> + <% auth_providers.each do |provider| %> + <li> + <a class="oauth-link" + href="<%= ApplicationController.root_context -%>/sessions/init/<%= provider.getKey().to_s %>" + title="Login with <%= provider.getName().to_s -%>"> + <img alt="<%= provider.getName().to_s -%>" width="24" height="24" + src="<%= ApplicationController.root_context + provider.getIconPath().to_s -%>"> + </a> + </li> + <% end %> + </ul> + </div> + <div class="text-right overflow-hidden"> + <button name="commit"><%= message('sessions.log_in') -%></button> + <a class="spacer-left" href="<%= home_path -%>"><%= message('cancel') -%></a> + </div> + </div> + </form> <script> diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/not_allowed_to_sign_up.html.erb b/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/not_allowed_to_sign_up.html.erb new file mode 100644 index 00000000000..dd8ddb1ce16 --- /dev/null +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/not_allowed_to_sign_up.html.erb @@ -0,0 +1,10 @@ +<table class="spaced"> + <tr> + <td align="center"> + + <div id="login_form"> + <p id="unauthorized"><%= params[:providerName] %> users are not allowed to signup</p> + </div> + </td> + </tr> +</table> diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/unauthorized.html.erb b/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/unauthorized.html.erb new file mode 100644 index 00000000000..d5526183541 --- /dev/null +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/views/sessions/unauthorized.html.erb @@ -0,0 +1,14 @@ +<table class="spaced"> + <tr> + <td align="center"> + + <% if flash[:error] %> + <div class="error"><%= flash[:error] %></div> + <% end %> + + <div id="login_form"> + <p id="unauthorized">You're not authorized to access this page. Please contact the administrator.</p> + </div> + </td> + </tr> +</table> diff --git a/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb b/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb index 8f28272d4a0..b97eff8c155 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb +++ b/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb @@ -14,10 +14,10 @@ module AuthenticatedSystem # Store the given user id in the session. def current_user=(new_user) if new_user - session[:user_id] = new_user.id + session['user_id'] = new_user.id @current_user = new_user else - session[:user_id] = nil + session['user_id'] = nil @current_user = false end end @@ -121,7 +121,7 @@ module AuthenticatedSystem # Called from #current_user. First attempt to login by the user id stored in the session. def login_from_session - self.current_user = User.find_by_id(session[:user_id]) if session[:user_id] + self.current_user = User.find_by_id(session['user_id']) if session['user_id'] end # Called from #current_user. Now, attempt to login by basic authentication information. @@ -171,7 +171,7 @@ module AuthenticatedSystem @current_user.forget_me if @current_user.is_a? User @current_user = false # not logged in, and don't do it for me kill_remember_cookie! # Kill client-side auth cookie - session[:user_id] = nil # keeps the session but kill our variable + session['user_id'] = nil # keeps the session but kill our variable # explicitly kill any other session variables you set end diff --git a/sonar-batch/src/main/java/org/sonar/batch/platform/DefaultServer.java b/sonar-batch/src/main/java/org/sonar/batch/platform/DefaultServer.java index 510f281ac0e..b747438a9f5 100644 --- a/sonar-batch/src/main/java/org/sonar/batch/platform/DefaultServer.java +++ b/sonar-batch/src/main/java/org/sonar/batch/platform/DefaultServer.java @@ -19,21 +19,18 @@ */ package org.sonar.batch.platform; +import java.io.File; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import javax.annotation.CheckForNull; import org.apache.commons.lang.StringUtils; - -import org.sonar.batch.bootstrap.GlobalProperties; import org.slf4j.LoggerFactory; -import org.sonar.api.batch.BatchSide; import org.sonar.api.CoreProperties; +import org.sonar.api.batch.BatchSide; import org.sonar.api.config.Settings; import org.sonar.api.platform.Server; - -import javax.annotation.CheckForNull; - -import java.io.File; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Date; +import org.sonar.batch.bootstrap.GlobalProperties; @BatchSide public class DefaultServer extends Server { @@ -87,6 +84,21 @@ public class DefaultServer extends Server { } @Override + public String getPublicRootUrl() { + return null; + } + + @Override + public boolean isDev() { + return false; + } + + @Override + public boolean isSecured() { + return false; + } + + @Override public String getURL() { return StringUtils.removeEnd(StringUtils.defaultIfBlank(props.property("sonar.host.url"), "http://localhost:9000"), "/"); } diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/platform/Server.java b/sonar-plugin-api/src/main/java/org/sonar/api/platform/Server.java index 286dab7d644..4fa6a3a5d1a 100644 --- a/sonar-plugin-api/src/main/java/org/sonar/api/platform/Server.java +++ b/sonar-plugin-api/src/main/java/org/sonar/api/platform/Server.java @@ -19,13 +19,11 @@ */ package org.sonar.api.platform; -import org.sonar.api.batch.BatchSide; -import org.sonar.api.server.ServerSide; - -import javax.annotation.CheckForNull; - import java.io.File; import java.util.Date; +import javax.annotation.CheckForNull; +import org.sonar.api.batch.BatchSide; +import org.sonar.api.server.ServerSide; /** * @since 2.2 @@ -48,6 +46,28 @@ public abstract class Server { public abstract String getContextPath(); /** + * Return the public root url, for instance : https://nemo.sonarqube.org. + * Default value is {@link org.sonar.api.CoreProperties#SERVER_BASE_URL_DEFAULT_VALUE} + * + * @since 5.4 + */ + public abstract String getPublicRootUrl(); + + /** + * The dev mode is enabled when the property sonar.web.dev is true. + * + * @since 5.4 + */ + public abstract boolean isDev(); + + /** + * Return whether or not the {#getPublicRootUrl} is started with https. + * + * @since 5.4 + */ + public abstract boolean isSecured(); + + /** * @return the server URL when executed from batch, else null. * @since 2.4 */ diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/BaseIdentityProvider.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/BaseIdentityProvider.java new file mode 100644 index 00000000000..6f17ee506fd --- /dev/null +++ b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/BaseIdentityProvider.java @@ -0,0 +1,67 @@ +/* + * 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.api.server.authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * @since 5.4 + */ +public interface BaseIdentityProvider extends IdentityProvider { + + /** + * Entry-point of authentication workflow. Executed by core when user + * clicks on the related button in login form (GET /sessions/init/{provider key}). + */ + void init(Context context); + + interface Context { + + /** + * Get the received HTTP request. + * Note - {@code getRequest().getSession()} must not be used in order to support + * future clustering of web servers without stateful server sessions. + */ + HttpServletRequest getRequest(); + + /** + * Get the HTTP response to send + */ + HttpServletResponse getResponse(); + + /** + * Return the server base URL + * @see org.sonar.api.platform.Server#getPublicRootUrl() + */ + String getServerBaseURL(); + + /** + * Authenticate and register the user into the platform. + * + * The first time a user is authenticated (and if {@link #allowsUsersToSignUp()} is true), a new user will be registered. + * Then, only user's name and email are updated. + * + * @throws NotAllowUserToSignUpException when {@link #allowsUsersToSignUp()} is false and a new user try to authenticate + */ + void authenticate(UserIdentity userIdentity); + + } +} diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/IdentityProvider.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/IdentityProvider.java new file mode 100644 index 00000000000..9944d2c51a7 --- /dev/null +++ b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/IdentityProvider.java @@ -0,0 +1,70 @@ +/* + * 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.api.server.authentication; + +import org.sonar.api.server.ServerSide; + +/** + * Entry-point to define a new Identity provider. + * Only one of this two interfaces can be used : + * <ul> + * <li>{@link OAuth2IdentityProvider}</li> for OAuth2 authentication + * <li>{@link BaseIdentityProvider}</li> for other kind of authentication + * </ul> + * + * @since 5.4 + */ +@ServerSide +public interface IdentityProvider { + + /** + * Unique key of provider, for example "github". + * Must not be blank. + */ + String getKey(); + + /** + * Name displayed in login form. + * Must not be blank. + */ + String getName(); + + /** + * URL path to the provider icon, as deployed at runtime, for example "/static/authgithub/github.svg" (in this + * case "authgithub" is the plugin key. Source file is "src/main/resources/static/github.svg"). Must not be blank. + * <p/> + * The recommended format is SVG with a size of 24x24 pixels. + * Other supported format is PNG, with a size of 48x48 pixels. + */ + String getIconPath(); + + /** + * Is the provider fully configured and enabled ? If {@code true}, then + * the provider is available in login form. + */ + boolean isEnabled(); + + /** + * Can users sign-up (connecting with their account for the first time) ? If {@code true}, + * then users can register and create their account into SonarQube, else only already + * registered users can login. + */ + boolean allowsUsersToSignUp(); +} diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/OAuth2IdentityProvider.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/OAuth2IdentityProvider.java new file mode 100644 index 00000000000..4409803ac10 --- /dev/null +++ b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/OAuth2IdentityProvider.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.api.server.authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * @since 5.4 + */ +public interface OAuth2IdentityProvider extends IdentityProvider { + + /** + * Entry-point of authentication workflow. Executed by core when user + * clicks on the related button in login form (GET /sessions/init/{provider key}). + */ + void init(InitContext context); + + /** + * This method is called when the identity provider has authenticated a user. + */ + void callback(CallbackContext context); + + interface OAuth2Context { + + /** + * The callback URL that must be used by the identity provider + */ + String getCallbackUrl(); + + /** + * Get the received HTTP request. + * Note - {@code getRequest().getSession()} must not be used in order to support + * future clustering of web servers without stateful server sessions. + */ + HttpServletRequest getRequest(); + + /** + * Get the HTTP response to send + */ + HttpServletResponse getResponse(); + } + + interface InitContext extends OAuth2Context { + + /** + * Generate a non-guessable state to prevent Cross Site Request Forgery. + */ + String generateCsrfState(); + + /** + * Redirect the request to the url. + * Can be used to redirect to the identity provider authentication url. + */ + void redirectTo(String url); + } + + interface CallbackContext extends OAuth2Context { + + /** + * Check that the state is valid. + * It should only be called If {@link InitContext#generateCsrfState()} was used in the init + */ + void verifyCsrfState(); + + /** + * Redirect the request to the requested page. + * Must be called at the end of {@link OAuth2IdentityProvider#callback(CallbackContext)} + */ + void redirectToRequestedPage(); + + /** + * Authenticate and register the user into the platform + */ + void authenticate(UserIdentity userIdentity); + } + +} diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/UserIdentity.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/UserIdentity.java new file mode 100644 index 00000000000..ae1564ac26f --- /dev/null +++ b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/UserIdentity.java @@ -0,0 +1,129 @@ +/* + * 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.api.server.authentication; + +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.commons.lang.StringUtils.isNotBlank; + +/** + * User information provided by the Identity Provider to be register into the platform. + * + * @since 5.4 + */ +@Immutable +public final class UserIdentity { + + private final String id; + private final String name; + private final String email; + + private UserIdentity(Builder builder) { + this.id = builder.id; + this.name = builder.name; + this.email = builder.email; + } + + /** + * Non-blank user ID, unique for the related {@link IdentityProvider}. If two {@link IdentityProvider} + * define two users with the same ID, then users are considered as different. + */ + public String getId() { + return id; + } + + /** + * Non-blank display name. Uniqueness is not mandatory, even it's recommended for easier search of users + * in webapp. + */ + public String getName() { + return name; + } + + /** + * Optional non-blank email. If defined, then it must be unique among all the users defined by all + * {@link IdentityProvider}. If not unique, then authentication will fail. + */ + @CheckForNull + public String getEmail() { + return email; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String id; + private String name; + private String email; + + private Builder() { + } + + /** + * @see UserIdentity#getId() + */ + public Builder setId(String id) { + this.id = id; + return this; + } + + /** + * @see UserIdentity#getName() + */ + public Builder setName(String name) { + this.name = name; + return this; + } + + /** + * @see UserIdentity#getEmail() + */ + public Builder setEmail(@Nullable String email) { + this.email = email; + return this; + } + + public UserIdentity build() { + validateId(id); + validateName(name); + validateEmail(email); + return new UserIdentity(this); + } + + private static void validateId(String id){ + checkArgument(isNotBlank(id), "User id must not be blank"); + checkArgument(id.length() <= 255 && id.length() >= 3, "User id size is incorrect (Between 3 and 255 characters)"); + } + + private static void validateName(String name){ + checkArgument(isNotBlank(name), "User name must not be blank"); + checkArgument(name.length() <= 200, "User name size is too big (200 characters max)"); + } + + private static void validateEmail(@Nullable String email){ + checkArgument(email == null || email.length() <= 100, "User email size is too big (100 characters max)"); + } + } +} diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/package-info.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/authentication/package-info.java new file mode 100644 index 00000000000..d5dd183915f --- /dev/null +++ b/sonar-plugin-api/src/main/java/org/sonar/api/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.api.server.authentication; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/sonar-plugin-api/src/test/java/org/sonar/api/server/authentication/UserIdentityTest.java b/sonar-plugin-api/src/test/java/org/sonar/api/server/authentication/UserIdentityTest.java new file mode 100644 index 00000000000..c74e1da0c0b --- /dev/null +++ b/sonar-plugin-api/src/test/java/org/sonar/api/server/authentication/UserIdentityTest.java @@ -0,0 +1,142 @@ +/* + * 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.api.server.authentication; + +import com.google.common.base.Strings; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UserIdentityTest { + + @Rule + public ExpectedException thrown= ExpectedException.none(); + + @Test + public void create_user() throws Exception { + UserIdentity underTest = UserIdentity.builder() + .setId("john") + .setName("John") + .setEmail("john@email.com") + .build(); + + assertThat(underTest.getId()).isEqualTo("john"); + assertThat(underTest.getName()).isEqualTo("John"); + assertThat(underTest.getEmail()).isEqualTo("john@email.com"); + } + + @Test + public void create_user_without_email() throws Exception { + UserIdentity underTest = UserIdentity.builder() + .setId("john") + .setName("John") + .build(); + + assertThat(underTest.getEmail()).isNull(); + } + + @Test + public void fail_when_id_is_null() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User id must not be blank"); + UserIdentity.builder() + .setName("John") + .setEmail("john@email.com") + .build(); + } + + @Test + public void fail_when_id_is_empty() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User id must not be blank"); + UserIdentity.builder() + .setId("") + .setName("John") + .setEmail("john@email.com") + .build(); + } + + @Test + public void fail_when_id_is_loo_long() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User id size is incorrect (Between 3 and 255 characters)"); + UserIdentity.builder() + .setId(Strings.repeat("1", 256)) + .setName("John") + .setEmail("john@email.com") + .build(); + } + + @Test + public void fail_when_id_is_loo_small() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User id size is incorrect (Between 3 and 255 characters)"); + UserIdentity.builder() + .setId("ab") + .setName("John") + .setEmail("john@email.com") + .build(); + } + + @Test + public void fail_when_name_is_null() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User name must not be blank"); + UserIdentity.builder() + .setId("john") + .setEmail("john@email.com") + .build(); + } + + @Test + public void fail_when_name_is_empty() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User name must not be blank"); + UserIdentity.builder() + .setId("john") + .setName("") + .setEmail("john@email.com") + .build(); + } + + @Test + public void fail_when_name_is_loo_long() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User name size is too big (200 characters max)"); + UserIdentity.builder() + .setId("john") + .setName(Strings.repeat("1", 201)) + .setEmail("john@email.com") + .build(); + } + + @Test + public void fail_when_email_is_loo_long() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("User email size is too big (100 characters max)"); + UserIdentity.builder() + .setId("john") + .setName("John") + .setEmail(Strings.repeat("1", 101)) + .build(); + } +} |