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;
LicensesPageTest.class,
// update center
UpdateCenterTest.class,
- RealmAuthenticationTest.class
- })
+ RealmAuthenticationTest.class,
+ SsoAuthenticationTest.class
+})
public class Category5Suite {
}
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;
}
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();
}
}
--- /dev/null
+/*
+ * 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).
+ * <p>
+ * 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<String> 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}");
+ }
+
+}
CredentialsAuthenticator.class,
RealmAuthenticator.class,
BasicAuthenticator.class,
- ValidateAction.class);
+ ValidateAction.class,
+ SsoAuthenticator.class);
}
}
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;
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;
this.jwtCsrfVerifier = jwtCsrfVerifier;
}
- public void generateToken(UserDto user, HttpServletRequest request, HttpServletResponse response) {
+ public void generateToken(UserDto user, Map<String, Object> 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.<String, Object>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<UserDto> validateToken(HttpServletRequest request, HttpServletResponse response) {
- Optional<UserDto> userDto = validate(request, response);
- if (userDto.isPresent()) {
- return userDto;
+ Optional<Token> token = getToken(request, response);
+ if (token.isPresent()) {
+ return Optional.of(token.get().getUserDto());
}
return Optional.empty();
}
- private Optional<UserDto> validate(HttpServletRequest request, HttpServletResponse response) {
- Optional<String> token = getTokenFromCookie(request);
- if (!token.isPresent()) {
+ public Optional<Token> getToken(HttpServletRequest request, HttpServletResponse response) {
+ Optional<String> encodedToken = getTokenFromCookie(request);
+ if (!encodedToken.isPresent()) {
return Optional.empty();
}
- return validateToken(token.get(), request, response);
+ return validateToken(encodedToken.get(), request, response);
}
private static Optional<String> getTokenFromCookie(HttpServletRequest request) {
return Optional.of(token);
}
- private Optional<UserDto> validateToken(String tokenEncoded, HttpServletRequest request, HttpServletResponse response) {
+ private Optional<Token> validateToken(String tokenEncoded, HttpServletRequest request, HttpServletResponse response) {
Optional<Claims> claims = jwtSerializer.decode(tokenEncoded);
if (!claims.isPresent()) {
return Optional.empty();
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);
}
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) {
}
return SESSION_TIMEOUT_DEFAULT_VALUE_IN_SECONDS;
}
+
+ public static class Token {
+ private final UserDto userDto;
+ private final Map<String, Object> properties;
+
+ Token(UserDto userDto, Map<String, Object> properties) {
+ this.userDto = userDto;
+ this.properties = properties;
+ }
+
+ public UserDto getUserDto() {
+ return userDto;
+ }
+
+ public Map<String, Object> getProperties() {
+ return properties;
+ }
+ }
}
--- /dev/null
+/*
+ * 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<String, String> 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<UserDto> authenticate(HttpServletRequest request, HttpServletResponse response) {
+ if (!settings.getBoolean(ENABLE_PARAM)) {
+ return Optional.empty();
+ }
+ Map<String, String> headerValuesByNames = getHeaders(request);
+ String login = getHeaderValue(headerValuesByNames, LOGIN_HEADER_PARAM);
+ if (login == null) {
+ return Optional.empty();
+ }
+ Optional<UserDto> 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<UserDto> getUserFromToken(HttpServletRequest request, HttpServletResponse response) {
+ Optional<JwtHttpHandler.Token> 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<String, String> 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<String, String> headerValuesByNames, String settingKey) {
+ return headerValuesByNames.get(getSettingValue(settingKey).toLowerCase(Locale.ENGLISH));
+ }
+
+ private static Map<String, String> getHeaders(HttpServletRequest request) {
+ Map<String, String> headers = new HashMap<>();
+ Collections.list(request.getHeaderNames()).forEach(header -> headers.put(header.toLowerCase(Locale.ENGLISH), request.getHeader(header)));
+ return headers;
+ }
+
+ private boolean hasHeader(Map<String, String> 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;
+ }
+ }
+}
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;
}
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<UserDto> authenticate(HttpServletRequest request, HttpServletResponse response) {
- Optional<UserDto> 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<UserDto> user = ssoAuthenticator.authenticate(request, response);
+ if (user.isPresent()) {
+ return user;
+ }
+ user = jwtHttpHandler.validateToken(request, response);
if (user.isPresent()) {
return user;
}
--- /dev/null
+/*
+ * 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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));
+ }
+
+}
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;
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 {
@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();
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();
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();
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);
@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();
@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();
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();
#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