From: Julien Lancelot Date: Thu, 27 Oct 2016 08:35:16 +0000 (+0200) Subject: SONAR-5430 User authentication by HTTP header X-Git-Tag: 6.2-RC1~248 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=0437299b4e9593501fdc34e94ccd0898704b1418;p=sonarqube.git SONAR-5430 User authentication by HTTP header --- diff --git a/it/it-tests/src/test/java/it/Category5Suite.java b/it/it-tests/src/test/java/it/Category5Suite.java index 4a775d07653..94b693679dd 100644 --- a/it/it-tests/src/test/java/it/Category5Suite.java +++ b/it/it-tests/src/test/java/it/Category5Suite.java @@ -26,6 +26,7 @@ import it.settings.LicensesPageTest; import it.settings.SettingsTestRestartingOrchestrator; import it.updateCenter.UpdateCenterTest; import it.user.RealmAuthenticationTest; +import it.user.SsoAuthenticationTest; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -43,8 +44,9 @@ import org.junit.runners.Suite; LicensesPageTest.class, // update center UpdateCenterTest.class, - RealmAuthenticationTest.class - }) + RealmAuthenticationTest.class, + SsoAuthenticationTest.class +}) public class Category5Suite { } diff --git a/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java b/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java index 07471d31fdc..2801b4e3d48 100644 --- a/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java +++ b/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java @@ -32,7 +32,6 @@ import org.junit.ClassRule; import org.junit.Test; import org.sonarqube.ws.client.GetRequest; import org.sonarqube.ws.client.WsClient; -import org.sonarqube.ws.client.WsResponse; import util.selenium.SeleneseTest; import util.user.UserRule; import util.user.Users; @@ -277,9 +276,9 @@ public class BaseIdentityProviderTest { } private static void authenticateWithFakeAuthProvider() { - WsResponse response = adminWsClient.wsConnector().call( - new GetRequest(("/sessions/init/" + FAKE_PROVIDER_KEY))); - assertThat(response.code()).isEqualTo(200); + adminWsClient.wsConnector().call( + new GetRequest(("/sessions/init/" + FAKE_PROVIDER_KEY))) + .failIfNotSuccessful(); } } diff --git a/it/it-tests/src/test/java/it/user/SsoAuthenticationTest.java b/it/it-tests/src/test/java/it/user/SsoAuthenticationTest.java new file mode 100644 index 00000000000..78816596e2d --- /dev/null +++ b/it/it-tests/src/test/java/it/user/SsoAuthenticationTest.java @@ -0,0 +1,159 @@ +/* + * 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 it.user; + +import com.google.common.base.Throwables; +import com.sonar.orchestrator.Orchestrator; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.apache.commons.io.Charsets; +import org.apache.commons.io.FileUtils; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import util.user.UserRule; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test SSO authentication (using HTTP headers). + *

+ * It starts its own server as it's using a different authentication system + */ +public class SsoAuthenticationTest { + + private static final String LOGIN_HEADER = "H-Login"; + private static final String NAME_HEADER = "H-Name"; + private static final String EMAIL_HEADER = "H-Email"; + private static final String GROUPS_HEADER = "H-Groups"; + + static final String USER_LOGIN = "tester"; + static final String USER_NAME = "Tester"; + static final String USER_EMAIL = "tester@email.com"; + + static final String GROUP_1 = "group-1"; + static final String GROUP_2 = "group-2"; + static final String GROUP_3 = "group-3"; + + @ClassRule + public static final Orchestrator orchestrator = Orchestrator.builderEnv() + .setServerProperty("sonar.sso.enable", "true") + .setServerProperty("sonar.sso.loginHeader", LOGIN_HEADER) + .setServerProperty("sonar.sso.nameHeader", NAME_HEADER) + .setServerProperty("sonar.sso.emailHeader", EMAIL_HEADER) + .setServerProperty("sonar.sso.groupsHeader", GROUPS_HEADER) + .build(); + + @ClassRule + public static UserRule USER_RULE = UserRule.from(orchestrator); + + @Before + public void resetData() throws Exception { + USER_RULE.resetUsers(); + } + + @Test + public void authenticate() { + call(USER_LOGIN, USER_NAME, USER_EMAIL, null); + + USER_RULE.verifyUserExists(USER_LOGIN, USER_NAME, USER_EMAIL); + } + + @Test + public void authenticate_with_only_login() throws Exception { + call(USER_LOGIN, null, null, null); + + USER_RULE.verifyUserExists(USER_LOGIN, USER_LOGIN, null); + } + + @Test + public void authenticate_with_groups() { + call(USER_LOGIN, null, null, GROUP_1); + + USER_RULE.verifyUserGroupMembership(USER_LOGIN, GROUP_1); + } + + @Test + public void synchronize_groups_when_authenticating_existing_user() throws Exception { + USER_RULE.createGroup(GROUP_1); + USER_RULE.createGroup(GROUP_2); + USER_RULE.createGroup(GROUP_3); + USER_RULE.createUser(USER_LOGIN, "password"); + USER_RULE.associateGroupsToUser(USER_LOGIN, GROUP_1, GROUP_2); + + call(USER_LOGIN, null, null, GROUP_2 + "," + GROUP_3); + + USER_RULE.verifyUserGroupMembership(USER_LOGIN, GROUP_2, GROUP_3); + } + + @Test + public void authentication_with_local_user_is_possible_when_no_header() throws Exception { + USER_RULE.createUser(USER_LOGIN, "password"); + + checkLocalAuthentication(USER_LOGIN, "password"); + } + + @Test + public void fail_when_login_is_invalid() throws Exception { + Response response = doCall("invalid login $", null, null, null); + + assertThat(response.code()).isEqualTo(500); + List logsLines = FileUtils.readLines(orchestrator.getServer().getLogs(), Charsets.UTF_8); + assertThat(logsLines).contains("org.sonar.server.exceptions.BadRequestException: user.bad_login"); + } + + private static Response call(String login, @Nullable String name, @Nullable String email, @Nullable String groups) { + return doCall(login, name, email, groups); + } + + private static Response doCall(String login, @Nullable String name, @Nullable String email, @Nullable String groups) { + Request.Builder requestBuilder = new Request.Builder().get().url(orchestrator.getServer().getUrl()) + .addHeader(LOGIN_HEADER, login); + if (name != null) { + requestBuilder.addHeader(NAME_HEADER, name); + } + if (email != null) { + requestBuilder.addHeader(EMAIL_HEADER, email); + } + if (groups != null) { + requestBuilder.addHeader(GROUPS_HEADER, groups); + } + try { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + .newCall(requestBuilder.build()).execute(); + } catch (IOException e) { + throw Throwables.propagate(e); + } + } + + private boolean checkLocalAuthentication(String login, String password) { + String result = orchestrator.getServer().wsClient(login, password).get("/api/authentication/validate"); + return result.contains("{\"valid\":true}"); + } + +} 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 index 9ca0e666a2a..4d810053907 100644 --- 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 @@ -44,6 +44,7 @@ public class AuthenticationModule extends Module { CredentialsAuthenticator.class, RealmAuthenticator.class, BasicAuthenticator.class, - ValidateAction.class); + ValidateAction.class, + SsoAuthenticator.class); } } diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java index 4ae491d5bbe..bcb9f5ba349 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java @@ -22,13 +22,14 @@ package org.sonar.server.authentication; import com.google.common.collect.ImmutableMap; import io.jsonwebtoken.Claims; +import java.util.Collections; import java.util.Date; +import java.util.Map; import java.util.Optional; import javax.annotation.Nullable; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.apache.commons.lang.time.DateUtils; import org.sonar.api.config.Settings; import org.sonar.api.server.ServerSide; import org.sonar.api.utils.System2; @@ -37,6 +38,7 @@ import org.sonar.db.DbSession; import org.sonar.db.user.UserDto; import static java.util.Objects.requireNonNull; +import static org.apache.commons.lang.time.DateUtils.addSeconds; import static org.elasticsearch.common.Strings.isNullOrEmpty; import static org.sonar.server.authentication.CookieUtils.findCookie; @@ -74,32 +76,38 @@ public class JwtHttpHandler { this.jwtCsrfVerifier = jwtCsrfVerifier; } - public void generateToken(UserDto user, HttpServletRequest request, HttpServletResponse response) { + public void generateToken(UserDto user, Map properties, HttpServletRequest request, HttpServletResponse response) { String csrfState = jwtCsrfVerifier.generateState(request, response, sessionTimeoutInSeconds); String token = jwtSerializer.encode(new JwtSerializer.JwtSession( user.getLogin(), sessionTimeoutInSeconds, - ImmutableMap.of( - LAST_REFRESH_TIME_PARAM, system2.now(), - CSRF_JWT_PARAM, csrfState))); + ImmutableMap.builder() + .putAll(properties) + .put(LAST_REFRESH_TIME_PARAM, system2.now()) + .put(CSRF_JWT_PARAM, csrfState) + .build())); response.addCookie(createCookie(request, JWT_COOKIE, token, sessionTimeoutInSeconds)); } + public void generateToken(UserDto user, HttpServletRequest request, HttpServletResponse response) { + generateToken(user, Collections.emptyMap(), request, response); + } + public Optional validateToken(HttpServletRequest request, HttpServletResponse response) { - Optional userDto = validate(request, response); - if (userDto.isPresent()) { - return userDto; + Optional token = getToken(request, response); + if (token.isPresent()) { + return Optional.of(token.get().getUserDto()); } return Optional.empty(); } - private Optional validate(HttpServletRequest request, HttpServletResponse response) { - Optional token = getTokenFromCookie(request); - if (!token.isPresent()) { + public Optional getToken(HttpServletRequest request, HttpServletResponse response) { + Optional encodedToken = getTokenFromCookie(request); + if (!encodedToken.isPresent()) { return Optional.empty(); } - return validateToken(token.get(), request, response); + return validateToken(encodedToken.get(), request, response); } private static Optional getTokenFromCookie(HttpServletRequest request) { @@ -115,7 +123,7 @@ public class JwtHttpHandler { return Optional.of(token); } - private Optional validateToken(String tokenEncoded, HttpServletRequest request, HttpServletResponse response) { + private Optional validateToken(String tokenEncoded, HttpServletRequest request, HttpServletResponse response) { Optional claims = jwtSerializer.decode(tokenEncoded); if (!claims.isPresent()) { return Optional.empty(); @@ -123,12 +131,12 @@ public class JwtHttpHandler { Date now = new Date(system2.now()); Claims token = claims.get(); - if (now.after(DateUtils.addSeconds(token.getIssuedAt(), SESSION_DISCONNECT_IN_SECONDS))) { + if (now.after(addSeconds(token.getIssuedAt(), SESSION_DISCONNECT_IN_SECONDS))) { return Optional.empty(); } jwtCsrfVerifier.verifyState(request, (String) token.get(CSRF_JWT_PARAM)); - if (now.after(DateUtils.addSeconds(getLastRefreshDate(token), SESSION_REFRESH_IN_SECONDS))) { + if (now.after(addSeconds(getLastRefreshDate(token), SESSION_REFRESH_IN_SECONDS))) { refreshToken(token, request, response); } @@ -136,7 +144,7 @@ public class JwtHttpHandler { if (!user.isPresent()) { return Optional.empty(); } - return Optional.of(user.get()); + return Optional.of(new Token(user.get(), claims.get())); } private static Date getLastRefreshDate(Claims token) { @@ -176,4 +184,22 @@ public class JwtHttpHandler { } return SESSION_TIMEOUT_DEFAULT_VALUE_IN_SECONDS; } + + public static class Token { + private final UserDto userDto; + private final Map properties; + + Token(UserDto userDto, Map properties) { + this.userDto = userDto; + this.properties = properties; + } + + public UserDto getUserDto() { + return userDto; + } + + public Map getProperties() { + return properties; + } + } } diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/SsoAuthenticator.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/SsoAuthenticator.java new file mode 100644 index 00000000000..239806115d9 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/SsoAuthenticator.java @@ -0,0 +1,181 @@ +/* + * 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.Splitter; +import com.google.common.collect.ImmutableMap; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import javax.annotation.CheckForNull; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.sonar.api.config.Settings; +import org.sonar.api.server.authentication.Display; +import org.sonar.api.server.authentication.IdentityProvider; +import org.sonar.api.server.authentication.UserIdentity; +import org.sonar.api.utils.System2; +import org.sonar.db.user.UserDto; + +import static org.apache.commons.lang.StringUtils.defaultIfBlank; +import static org.apache.commons.lang.time.DateUtils.addMinutes; +import static org.sonar.server.user.UserUpdater.SQ_AUTHORITY; + +public class SsoAuthenticator { + + private static final Splitter COMA_SPLITTER = Splitter.on(",").trimResults().omitEmptyStrings(); + + private static final String ENABLE_PARAM = "sonar.sso.enable"; + + private static final String LOGIN_HEADER_PARAM = "sonar.sso.loginHeader"; + private static final String LOGIN_HEADER_DEFAULT_VALUE = "X-Forwarded-Login"; + + private static final String NAME_HEADER_PARAM = "sonar.sso.nameHeader"; + private static final String NAME_HEADER_DEFAULT_VALUE = "X-Forwarded-Name"; + + private static final String EMAIL_HEADER_PARAM = "sonar.sso.emailHeader"; + private static final String EMAIL_HEADER_DEFAULT_VALUE = "X-Forwarded-Email"; + + private static final String GROUPS_HEADER_PARAM = "sonar.sso.groupsHeader"; + private static final String GROUPS_HEADER_DEFAULT_VALUE = "X-Forwarded-Groups"; + + private static final String REFRESH_INTERVAL_PARAM = "sonar.sso.refreshIntervalInMinutes"; + private static final String REFRESH_INTERVAL_DEFAULT_VALUE = "5"; + + private static final String LAST_REFRESH_TIME_TOKEN_PARAM = "ssoLastRefreshTime"; + + private static final Map DEFAULT_VALUES_BY_PARAMETERS = ImmutableMap.of( + LOGIN_HEADER_PARAM, LOGIN_HEADER_DEFAULT_VALUE, + NAME_HEADER_PARAM, NAME_HEADER_DEFAULT_VALUE, + EMAIL_HEADER_PARAM, EMAIL_HEADER_DEFAULT_VALUE, + GROUPS_HEADER_PARAM, GROUPS_HEADER_DEFAULT_VALUE, + REFRESH_INTERVAL_PARAM, REFRESH_INTERVAL_DEFAULT_VALUE); + + private final System2 system2; + private final Settings settings; + private final UserIdentityAuthenticator userIdentityAuthenticator; + private final JwtHttpHandler jwtHttpHandler; + + public SsoAuthenticator(System2 system2, Settings settings, UserIdentityAuthenticator userIdentityAuthenticator, JwtHttpHandler jwtHttpHandler) { + this.system2 = system2; + this.settings = settings; + this.userIdentityAuthenticator = userIdentityAuthenticator; + this.jwtHttpHandler = jwtHttpHandler; + } + + public Optional authenticate(HttpServletRequest request, HttpServletResponse response) { + if (!settings.getBoolean(ENABLE_PARAM)) { + return Optional.empty(); + } + Map headerValuesByNames = getHeaders(request); + String login = getHeaderValue(headerValuesByNames, LOGIN_HEADER_PARAM); + if (login == null) { + return Optional.empty(); + } + Optional user = getUserFromToken(request, response); + if (user.isPresent() && login.equals(user.get().getLogin())) { + return user; + } + + UserDto userDto = doAuthenticate(headerValuesByNames, login); + jwtHttpHandler.generateToken(userDto, ImmutableMap.of(LAST_REFRESH_TIME_TOKEN_PARAM, system2.now()), request, response); + return Optional.of(userDto); + } + + private Optional getUserFromToken(HttpServletRequest request, HttpServletResponse response) { + Optional token = jwtHttpHandler.getToken(request, response); + if (!token.isPresent()) { + return Optional.empty(); + } + Date now = new Date(system2.now()); + int refreshIntervalInMinutes = Integer.parseInt(getSettingValue(REFRESH_INTERVAL_PARAM)); + Long lastFreshTime = (Long) token.get().getProperties().get(LAST_REFRESH_TIME_TOKEN_PARAM); + if (lastFreshTime == null || now.after(addMinutes(new Date(lastFreshTime), refreshIntervalInMinutes))) { + return Optional.empty(); + } + return Optional.of(token.get().getUserDto()); + } + + private UserDto doAuthenticate(Map headerValuesByNames, String login) { + String name = getHeaderValue(headerValuesByNames, NAME_HEADER_PARAM); + String email = getHeaderValue(headerValuesByNames, EMAIL_HEADER_PARAM); + UserIdentity.Builder userIdentityBuilder = UserIdentity.builder() + .setLogin(login) + .setName(name == null ? login : name) + .setEmail(email) + .setProviderLogin(login); + if (hasHeader(headerValuesByNames, GROUPS_HEADER_PARAM)) { + String groupsValue = getHeaderValue(headerValuesByNames, GROUPS_HEADER_PARAM); + userIdentityBuilder.setGroups(groupsValue == null ? Collections.emptySet() : new HashSet<>(COMA_SPLITTER.splitToList(groupsValue))); + } + return userIdentityAuthenticator.authenticate(userIdentityBuilder.build(), new SsoIdentityProvider()); + } + + @CheckForNull + private String getHeaderValue(Map headerValuesByNames, String settingKey) { + return headerValuesByNames.get(getSettingValue(settingKey).toLowerCase(Locale.ENGLISH)); + } + + private static Map getHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + Collections.list(request.getHeaderNames()).forEach(header -> headers.put(header.toLowerCase(Locale.ENGLISH), request.getHeader(header))); + return headers; + } + + private boolean hasHeader(Map headerValuesByNames, String settingKey) { + return headerValuesByNames.keySet().contains(getSettingValue(settingKey).toLowerCase(Locale.ENGLISH)); + } + + private String getSettingValue(String settingKey) { + return defaultIfBlank(settings.getString(settingKey), DEFAULT_VALUES_BY_PARAMETERS.get(settingKey)); + } + + private static class SsoIdentityProvider implements IdentityProvider { + @Override + public String getKey() { + return SQ_AUTHORITY; + } + + @Override + public String getName() { + return getKey(); + } + + @Override + public Display getDisplay() { + return null; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean allowsUsersToSignUp() { + return true; + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/UserSessionInitializer.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/UserSessionInitializer.java index fb7a42fec48..4f02daea4e0 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/authentication/UserSessionInitializer.java +++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/UserSessionInitializer.java @@ -72,14 +72,16 @@ public class UserSessionInitializer { private final Settings settings; private final JwtHttpHandler jwtHttpHandler; private final BasicAuthenticator basicAuthenticator; + private final SsoAuthenticator ssoAuthenticator; private final ThreadLocalUserSession threadLocalSession; public UserSessionInitializer(DbClient dbClient, Settings settings, JwtHttpHandler jwtHttpHandler, BasicAuthenticator basicAuthenticator, - ThreadLocalUserSession threadLocalSession) { + SsoAuthenticator ssoAuthenticator, ThreadLocalUserSession threadLocalSession) { this.dbClient = dbClient; this.settings = settings; this.jwtHttpHandler = jwtHttpHandler; this.basicAuthenticator = basicAuthenticator; + this.ssoAuthenticator = ssoAuthenticator; this.threadLocalSession = threadLocalSession; } @@ -121,9 +123,14 @@ public class UserSessionInitializer { threadLocalSession.unload(); } - // Try first to authenticate from JWT token, then try from basic http header + // Try first to authenticate from SSO, then JWT token, then try from basic http header private Optional authenticate(HttpServletRequest request, HttpServletResponse response) { - Optional user = jwtHttpHandler.validateToken(request, response); + // SSO authentication should come first in order to update JWT if user from header is not the same is user from JWT + Optional user = ssoAuthenticator.authenticate(request, response); + if (user.isPresent()) { + return user; + } + user = jwtHttpHandler.validateToken(request, response); if (user.isPresent()) { return user; } diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/SsoAuthenticatorTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/SsoAuthenticatorTest.java new file mode 100644 index 00000000000..99c3bb511ea --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/SsoAuthenticatorTest.java @@ -0,0 +1,404 @@ +/* + * 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.collect.ImmutableMap; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; +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.config.MapSettings; +import org.sonar.api.config.Settings; +import org.sonar.api.utils.System2; +import org.sonar.api.utils.internal.AlwaysIncreasingSystem2; +import org.sonar.core.util.stream.Collectors; +import org.sonar.db.DbTester; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.organization.DefaultOrganizationProvider; +import org.sonar.server.organization.TestDefaultOrganizationProvider; +import org.sonar.server.user.NewUserNotifier; +import org.sonar.server.user.UserUpdater; +import org.sonar.server.user.index.UserIndexer; + +import static java.util.Arrays.stream; +import static org.assertj.core.api.Java6Assertions.assertThat; +import static org.junit.rules.ExpectedException.none; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyMap; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; +import static org.sonar.db.user.UserTesting.newUserDto; + +public class SsoAuthenticatorTest { + + @Rule + public ExpectedException expectedException = none(); + + @Rule + public DbTester db = DbTester.create(new AlwaysIncreasingSystem2()); + + private static final String DEFAULT_LOGIN = "john"; + private static final String DEFAULT_NAME = "John"; + private static final String DEFAULT_EMAIL = "john@doo.com"; + private static final String GROUP1 = "dev"; + private static final String GROUP2 = "admin"; + private static final String GROUPS = GROUP1 + "," + GROUP2; + + private static final Long NOW = 1_000_000L; + private static final Long CLOSE_REFRESH_TIME = NOW - 1_000L; + + private static final UserDto DEFAULT_USER = newUserDto() + .setLogin(DEFAULT_LOGIN) + .setName(DEFAULT_NAME) + .setEmail(DEFAULT_EMAIL) + .setExternalIdentity(DEFAULT_LOGIN) + .setExternalIdentityProvider("sonarqube"); + + private GroupDto group1; + private GroupDto group2; + + private System2 system2 = mock(System2.class); + private Settings settings = new MapSettings(); + + private DefaultOrganizationProvider defaultOrganizationProvider = TestDefaultOrganizationProvider.from(db); + private UserIdentityAuthenticator userIdentityAuthenticator = new UserIdentityAuthenticator( + db.getDbClient(), + new UserUpdater(mock(NewUserNotifier.class), settings, db.getDbClient(), mock(UserIndexer.class), System2.INSTANCE, defaultOrganizationProvider), + defaultOrganizationProvider); + + private HttpServletResponse response = mock(HttpServletResponse.class); + private JwtHttpHandler jwtHttpHandler = mock(JwtHttpHandler.class); + + private SsoAuthenticator underTest = new SsoAuthenticator(system2, settings, userIdentityAuthenticator, jwtHttpHandler); + + @Before + public void setUp() throws Exception { + when(system2.now()).thenReturn(NOW); + group1 = db.users().insertGroup(db.getDefaultOrganization(), GROUP1); + group2 = db.users().insertGroup(db.getDefaultOrganization(), GROUP2); + db.commit(); + } + + @Test + public void create_user_when_authenticating_new_user() throws Exception { + enableSso(); + setNotUserInToken(); + HttpServletRequest request = createRequest(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, GROUPS); + + underTest.authenticate(request, response); + + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, group1, group2); + verifyTokenIsUpdated(NOW); + } + + @Test + public void use_login_when_name_is_not_provided() throws Exception { + enableSso(); + setNotUserInToken(); + + underTest.authenticate(createRequest(DEFAULT_LOGIN, null, null, null), response); + + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_LOGIN, null); + } + + @Test + public void update_user_when_authenticating_exiting_user() throws Exception { + enableSso(); + setNotUserInToken(); + insertUser(newUserDto().setLogin(DEFAULT_LOGIN).setName("old name").setEmail("old email"), group1); + // Name, email and groups are different + HttpServletRequest request = createRequest(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, GROUP2); + + underTest.authenticate(request, response); + + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, group2); + verifyTokenIsUpdated(NOW); + } + + @Test + public void remove_groups_when_group_headers_is_empty() throws Exception { + enableSso(); + setNotUserInToken(); + insertUser(DEFAULT_USER, group1); + + underTest.authenticate(createRequest(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, ""), response); + + verityUserHasNoGroup(DEFAULT_LOGIN); + } + + @Test + public void remove_groups_when_group_headers_is_null() throws Exception { + enableSso(); + setNotUserInToken(); + insertUser(DEFAULT_USER, group1); + Map headerValuesByName = new HashMap<>(); + headerValuesByName.put("X-Forwarded-Login", DEFAULT_LOGIN); + headerValuesByName.put("X-Forwarded-Groups", null); + + underTest.authenticate(createRequest(headerValuesByName), response); + + verityUserHasNoGroup(DEFAULT_LOGIN); + } + + @Test + public void does_not_update_groups_when_no_group_headers() throws Exception { + enableSso(); + setNotUserInToken(); + insertUser(DEFAULT_USER, group1); + + underTest.authenticate(createRequest(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, null), response); + + verityUserGroups(DEFAULT_LOGIN, group1); + } + + @Test + public void does_not_update_user_when_user_is_in_token_and_refresh_time_is_close() throws Exception { + enableSso(); + UserDto user = insertUser(DEFAULT_USER, group1); + setUserInToken(user, CLOSE_REFRESH_TIME); + HttpServletRequest request = createRequest(DEFAULT_LOGIN, "new name", "new email", GROUP2); + + underTest.authenticate(request, response); + + // User is not updated + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, group1); + verifyTokenIsNotUpdated(); + } + + @Test + public void update_user_when_user_in_token_but_refresh_time_is_old() throws Exception { + enableSso(); + UserDto user = insertUser(DEFAULT_USER, group1); + // Refresh time was updated 6 minutes ago => more than 5 minutes + setUserInToken(user, NOW - 6 * 60 * 1000L); + HttpServletRequest request = createRequest(DEFAULT_LOGIN, "new name", "new email", GROUP2); + + underTest.authenticate(request, response); + + // User is updated + verifyUserInDb(DEFAULT_LOGIN, "new name", "new email", group2); + verifyTokenIsUpdated(NOW); + } + + @Test + public void update_user_when_user_in_token_but_no_refresh_time() throws Exception { + enableSso(); + UserDto user = insertUser(DEFAULT_USER, group1); + setUserInToken(user, null); + HttpServletRequest request = createRequest(DEFAULT_LOGIN, "new name", "new email", GROUP2); + + underTest.authenticate(request, response); + + // User is updated + verifyUserInDb(DEFAULT_LOGIN, "new name", "new email", group2); + verifyTokenIsUpdated(NOW); + } + + @Test + public void use_refresh_time_from_settings() throws Exception { + enableSso(); + settings.setProperty("sonar.sso.refreshIntervalInMinutes", "10"); + UserDto user = insertUser(DEFAULT_USER, group1); + // Refresh time was updated 6 minutes ago => less than 10 minutes ago so not updated + setUserInToken(user, NOW - 6 * 60 * 1000L); + HttpServletRequest request = createRequest(DEFAULT_LOGIN, "new name", "new email", GROUP2); + + underTest.authenticate(request, response); + + // User is not updated + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, group1); + verifyTokenIsNotUpdated(); + } + + @Test + public void update_user_when_login_from_token_is_different_than_login_from_request() throws Exception { + enableSso(); + insertUser(DEFAULT_USER, group1); + setUserInToken(DEFAULT_USER, CLOSE_REFRESH_TIME); + HttpServletRequest request = createRequest("AnotherLogin", "Another name", "Another email", GROUP2); + + underTest.authenticate(request, response); + + verifyUserInDb("AnotherLogin", "Another name", "Another email", group2); + verifyTokenIsUpdated(NOW); + } + + @Test + public void use_headers_from_settings() throws Exception { + enableSso(); + setNotUserInToken(); + settings.setProperty("sonar.sso.loginHeader", "head-login"); + settings.setProperty("sonar.sso.nameHeader", "head-name"); + settings.setProperty("sonar.sso.emailHeader", "head-email"); + settings.setProperty("sonar.sso.groupsHeader", "head-groups"); + HttpServletRequest request = createRequest(ImmutableMap.of("head-login", DEFAULT_LOGIN, "head-name", DEFAULT_NAME, "head-email", DEFAULT_EMAIL, "head-groups", GROUPS)); + + underTest.authenticate(request, response); + + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, group1, group2); + } + + @Test + public void detect_group_header_even_with_wrong_case() throws Exception { + enableSso(); + setNotUserInToken(); + settings.setProperty("sonar.sso.loginHeader", "login"); + settings.setProperty("sonar.sso.nameHeader", "name"); + settings.setProperty("sonar.sso.emailHeader", "email"); + settings.setProperty("sonar.sso.groupsHeader", "Groups"); + HttpServletRequest request = createRequest(ImmutableMap.of("login", DEFAULT_LOGIN, "name", DEFAULT_NAME, "email", DEFAULT_EMAIL, "groups", GROUPS)); + + underTest.authenticate(request, response); + + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, group1, group2); + } + + @Test + public void trim_groups() throws Exception { + enableSso(); + setNotUserInToken(); + HttpServletRequest request = createRequest(DEFAULT_LOGIN, null, null, " dev , admin "); + + underTest.authenticate(request, response); + + verifyUserInDb(DEFAULT_LOGIN, DEFAULT_LOGIN, null, group1, group2); + } + + @Test + public void does_not_authenticate_when_no_header() throws Exception { + enableSso(); + setNotUserInToken(); + + underTest.authenticate(createRequest(Collections.emptyMap()), response); + + verifyUserNotAuthenticated(); + verifyTokenIsNotUpdated(); + } + + @Test + public void does_not_authenticate_when_not_enabled() throws Exception { + settings.setProperty("sonar.sso.enable", false); + + underTest.authenticate(createRequest(DEFAULT_LOGIN, DEFAULT_NAME, DEFAULT_EMAIL, GROUPS), response); + + verifyUserNotAuthenticated(); + verifyZeroInteractions(jwtHttpHandler); + } + + private void enableSso() { + settings.setProperty("sonar.sso.enable", true); + } + + private void setUserInToken(UserDto user, @Nullable Long lastRefreshTime) { + when(jwtHttpHandler.getToken(any(HttpServletRequest.class), any(HttpServletResponse.class))) + .thenReturn(Optional.of(new JwtHttpHandler.Token( + user, + lastRefreshTime == null ? Collections.emptyMap() : ImmutableMap.of("ssoLastRefreshTime", lastRefreshTime)))); + } + + private void setNotUserInToken() { + when(jwtHttpHandler.getToken(any(HttpServletRequest.class), any(HttpServletResponse.class))).thenReturn(Optional.empty()); + } + + private UserDto insertUser(UserDto user, GroupDto... groups) { + db.users().insertUser(user); + stream(groups).forEach(group -> db.users().insertMember(group, user)); + db.commit(); + return user; + } + + private static HttpServletRequest createRequest(Map headerValuesByName) { + HttpServletRequest request = mock(HttpServletRequest.class); + setHeaders(request, headerValuesByName); + return request; + } + + private static HttpServletRequest createRequest(String login, @Nullable String name, @Nullable String email, @Nullable String groups) { + Map headerValuesByName = new HashMap<>(); + headerValuesByName.put("X-Forwarded-Login", login); + if (name != null) { + headerValuesByName.put("X-Forwarded-Name", name); + } + if (email != null) { + headerValuesByName.put("X-Forwarded-Email", email); + } + if (groups != null) { + headerValuesByName.put("X-Forwarded-Groups", groups); + } + HttpServletRequest request = mock(HttpServletRequest.class); + setHeaders(request, headerValuesByName); + return request; + } + + private static void setHeaders(HttpServletRequest request, Map valuesByName) { + valuesByName.entrySet().forEach(entry -> when(request.getHeader(entry.getKey())).thenReturn(entry.getValue())); + when(request.getHeaderNames()).thenReturn(Collections.enumeration(valuesByName.keySet())); + } + + private void verifyUserInDb(String expectedLogin, String expectedName, @Nullable String expectedEmail, GroupDto... expectedGroups) { + UserDto userDto = db.users().selectUserByLogin(expectedLogin).get(); + assertThat(userDto.isActive()).isTrue(); + assertThat(userDto.getName()).isEqualTo(expectedName); + assertThat(userDto.getEmail()).isEqualTo(expectedEmail); + assertThat(userDto.getExternalIdentity()).isEqualTo(expectedLogin); + assertThat(userDto.getExternalIdentityProvider()).isEqualTo("sonarqube"); + verityUserGroups(expectedLogin, expectedGroups); + } + + private void verityUserGroups(String login, GroupDto... expectedGroups) { + UserDto userDto = db.users().selectUserByLogin(login).get(); + if (expectedGroups.length == 0) { + assertThat(db.users().selectGroupIdsOfUser(userDto)).isEmpty(); + } else { + assertThat(db.users().selectGroupIdsOfUser(userDto)).containsOnly(stream(expectedGroups).map(GroupDto::getId).collect(Collectors.toList()).toArray(new Long[] {})); + } + } + + private void verityUserHasNoGroup(String login) { + verityUserGroups(login); + } + + private void verifyUserNotAuthenticated() { + assertThat(db.countRowsOfTable(db.getSession(), "users")).isZero(); + verifyTokenIsNotUpdated(); + } + + private void verifyTokenIsUpdated(long refreshTime) { + verify(jwtHttpHandler).generateToken(any(UserDto.class), eq(ImmutableMap.of("ssoLastRefreshTime", refreshTime)), any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + private void verifyTokenIsNotUpdated() { + verify(jwtHttpHandler, never()).generateToken(any(UserDto.class), anyMap(), any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/UserSessionInitializerTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/UserSessionInitializerTest.java index 9f5501b9f86..36cc8e38e18 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/authentication/UserSessionInitializerTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/UserSessionInitializerTest.java @@ -26,8 +26,8 @@ import javax.servlet.http.HttpServletResponse; import org.junit.Before; import org.junit.Rule; import org.junit.Test; -import org.sonar.api.config.Settings; import org.sonar.api.config.MapSettings; +import org.sonar.api.config.Settings; import org.sonar.api.utils.System2; import org.sonar.db.DbClient; import org.sonar.db.DbSession; @@ -66,12 +66,13 @@ public class UserSessionInitializerTest { JwtHttpHandler jwtHttpHandler = mock(JwtHttpHandler.class); BasicAuthenticator basicAuthenticator = mock(BasicAuthenticator.class); + SsoAuthenticator ssoAuthenticator = mock(SsoAuthenticator.class); Settings settings = new MapSettings(); UserDto user = newUserDto(); - UserSessionInitializer underTest = new UserSessionInitializer(dbClient, settings, jwtHttpHandler, basicAuthenticator, userSession); + UserSessionInitializer underTest = new UserSessionInitializer(dbClient, settings, jwtHttpHandler, basicAuthenticator, ssoAuthenticator, userSession); @Before public void setUp() throws Exception { @@ -112,6 +113,7 @@ public class UserSessionInitializerTest { @Test public void validate_session_from_token() throws Exception { when(userSession.isLoggedIn()).thenReturn(true); + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.empty()); when(jwtHttpHandler.validateToken(request, response)).thenReturn(Optional.of(user)); assertThat(underTest.initUserSession(request, response)).isTrue(); @@ -124,6 +126,7 @@ public class UserSessionInitializerTest { public void validate_session_from_basic_authentication() throws Exception { when(userSession.isLoggedIn()).thenReturn(false).thenReturn(true); when(basicAuthenticator.authenticate(request)).thenReturn(Optional.of(user)); + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.empty()); when(jwtHttpHandler.validateToken(request, response)).thenReturn(Optional.empty()); assertThat(underTest.initUserSession(request, response)).isTrue(); @@ -134,8 +137,22 @@ public class UserSessionInitializerTest { verify(response, never()).setStatus(anyInt()); } + @Test + public void validate_session_from_sso() throws Exception { + when(userSession.isLoggedIn()).thenReturn(true); + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.of(user)); + when(jwtHttpHandler.validateToken(request, response)).thenReturn(Optional.empty()); + + assertThat(underTest.initUserSession(request, response)).isTrue(); + + verify(ssoAuthenticator).authenticate(request, response); + verify(jwtHttpHandler, never()).validateToken(request, response); + verify(response, never()).setStatus(anyInt()); + } + @Test public void return_code_401_when_invalid_token_exception() throws Exception { + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.empty()); doThrow(new UnauthorizedException("invalid token")).when(jwtHttpHandler).validateToken(request, response); assertThat(underTest.initUserSession(request, response)).isTrue(); @@ -148,6 +165,7 @@ public class UserSessionInitializerTest { public void return_code_401_when_not_authenticated_and_with_force_authentication() throws Exception { when(userSession.isLoggedIn()).thenReturn(false); when(basicAuthenticator.authenticate(request)).thenReturn(Optional.empty()); + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.empty()); when(jwtHttpHandler.validateToken(request, response)).thenReturn(Optional.empty()); settings.setProperty("sonar.forceAuthentication", true); @@ -160,6 +178,7 @@ public class UserSessionInitializerTest { @Test public void return_401_and_stop_on_ws() throws Exception { when(request.getRequestURI()).thenReturn("/api/issues"); + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.empty()); doThrow(new UnauthorizedException("invalid token")).when(jwtHttpHandler).validateToken(request, response); assertThat(underTest.initUserSession(request, response)).isFalse(); @@ -171,6 +190,7 @@ public class UserSessionInitializerTest { @Test public void return_401_and_stop_on_batch_ws() throws Exception { when(request.getRequestURI()).thenReturn("/batch/global"); + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.empty()); doThrow(new UnauthorizedException("invalid token")).when(jwtHttpHandler).validateToken(request, response); assertThat(underTest.initUserSession(request, response)).isFalse(); @@ -190,6 +210,7 @@ public class UserSessionInitializerTest { private void assertPathIsNotIgnored(String path) { when(request.getRequestURI()).thenReturn(path); + when(ssoAuthenticator.authenticate(request, response)).thenReturn(Optional.empty()); when(jwtHttpHandler.validateToken(request, response)).thenReturn(Optional.of(user)); assertThat(underTest.initUserSession(request, response)).isTrue(); diff --git a/sonar-application/src/main/assembly/conf/sonar.properties b/sonar-application/src/main/assembly/conf/sonar.properties index f34e1cd4c2e..a39c648033f 100644 --- a/sonar-application/src/main/assembly/conf/sonar.properties +++ b/sonar-application/src/main/assembly/conf/sonar.properties @@ -249,6 +249,33 @@ #sonar.web.accessLogs.pattern=combined +#-------------------------------------------------------------------------------------------------- +# AUTHENTICATION + +# Enable authentication using HTTP headers +#sonar.sso.enable=false + +# Name of the header to get the user login. +# Only alphanumeric, '.' and '@' characters are allowed +#sonar.sso.loginHeader=X-Forwarded-Login + +# Name of the header to get the user name +#sonar.sso.nameHeader=X-Forwarded-Name + +# Name of the header to get the user email (optional) +#sonar.sso.emailHeader=X-Forwarded-Email + +# Name of the header to get the list of user groups, separated by comma (optional). +# If the sonar.sso.groupsHeader is set, the user will belong to those groups if groups exist in SonarQube. +# If none of the provided groups exists in SonarQube, the user won't belong to any group. +# Note that the default group will NOT be automatically added when using SSO, it should be provided in the groups list, if needed. +#sonar.sso.groupsHeader=X-Forwarded-Groups + +# Interval used to know when to refresh name, email and groups. +# During this interval, if for instance the name of the user is changed in the header, it will only be updated after X minutes. +#sonar.sso.refreshIntervalInMinutes=5 + + #-------------------------------------------------------------------------------------------------- # OTHERS