Browse Source

SONAR-5430 User authentication by HTTP header - WIP

tags/6.2-RC1
Julien Lancelot 7 years ago
parent
commit
59616bcca2

+ 158
- 0
server/sonar-server/src/main/java/org/sonar/server/authentication/SsoAuthenticator.java View File

@@ -0,0 +1,158 @@
/*
* 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.HashSet;
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.db.user.UserDto;

import static org.apache.commons.lang.StringUtils.defaultIfBlank;
import static org.apache.commons.lang.StringUtils.isEmpty;
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 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 Settings settings;
private final UserIdentityAuthenticator userIdentityAuthenticator;
private final JwtHttpHandler jwtHttpHandler;

public SsoAuthenticator(Settings settings, UserIdentityAuthenticator userIdentityAuthenticator, JwtHttpHandler jwtHttpHandler) {
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();
}
String login = getHeaderValue(request, LOGIN_HEADER_PARAM);
if (login == null) {
return Optional.empty();
}
UserDto userDto = doAuthenticate(request, login);

Optional<UserDto> userFromToken = jwtHttpHandler.validateToken(request, response);
if (userFromToken.isPresent() && userDto.getLogin().equals(userFromToken.get().getLogin())) {
// User is already authenticated
return userFromToken;
}
jwtHttpHandler.generateToken(userDto, request, response);
return Optional.of(userDto);
}

private UserDto doAuthenticate(HttpServletRequest request, String login) {
String name = getHeaderValue(request, NAME_HEADER_PARAM);
String email = getHeaderValue(request, EMAIL_HEADER_PARAM);
UserIdentity.Builder userIdentityBuilder = UserIdentity.builder()
.setLogin(login)
.setName(name == null ? login : name)
.setEmail(email)
.setProviderLogin(login);
if (hasHeader(request, GROUPS_HEADER_PARAM)) {
String groupsValue = getHeaderValue(request, 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(HttpServletRequest request, String settingKey) {
String headerName = getSettingValue(settingKey);
if (!isEmpty(headerName)) {
return request.getHeader(headerName);
}
return null;
}

private boolean hasHeader(HttpServletRequest request, String settingKey) {
String headerName = getSettingValue(settingKey);
return Collections.list(request.getHeaderNames()).stream().anyMatch(header -> header.equals(headerName));
}

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;
}
}
}

+ 10
- 3
server/sonar-server/src/main/java/org/sonar/server/authentication/UserSessionInitializer.java View File

@@ -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<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;
}

+ 190
- 0
server/sonar-server/src/test/java/org/sonar/server/authentication/SsoAuthenticatorTest.java View File

@@ -0,0 +1,190 @@
/*
* 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.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.mockito.ArgumentCaptor;
import org.sonar.api.config.MapSettings;
import org.sonar.api.config.Settings;
import org.sonar.api.server.authentication.IdentityProvider;
import org.sonar.api.server.authentication.UserIdentity;
import org.sonar.db.user.UserDto;

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.Mockito.mock;
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();

private static final String LOGIN = "john";
private static final String NAME = "John";
private static final String EMAIL = "john@doo.com";
private static final String GROUPS = "dev,admin";

private static final UserDto USER = newUserDto()
.setLogin(LOGIN)
.setName(NAME)
.setEmail(EMAIL);

private Settings settings = new MapSettings();

private UserIdentityAuthenticator userIdentityAuthenticator = mock(UserIdentityAuthenticator.class);

private HttpServletRequest request = mock(HttpServletRequest.class);
private HttpServletResponse response = mock(HttpServletResponse.class);
private JwtHttpHandler jwtHttpHandler = mock(JwtHttpHandler.class);

private ArgumentCaptor<UserIdentity> userIdentityCaptor = ArgumentCaptor.forClass(UserIdentity.class);
private ArgumentCaptor<IdentityProvider> identityProviderCaptor = ArgumentCaptor.forClass(IdentityProvider.class);

private SsoAuthenticator underTest = new SsoAuthenticator(settings, userIdentityAuthenticator, jwtHttpHandler);

@Test
public void authenticate() throws Exception {
enableSsoAndSimulateNewUser();
defineHeadersFromDefaultValues(LOGIN, NAME, EMAIL, GROUPS);

underTest.authenticate(request, response);

verifyUser(LOGIN, NAME, EMAIL, "dev", "admin");
verify(jwtHttpHandler).validateToken(request, response);
}

@Test
public void use_login_when_name_is_not_provided() throws Exception {
enableSsoAndSimulateNewUser();
defineHeadersFromDefaultValues(LOGIN, null, null, null);

underTest.authenticate(request, response);

verifyUser(LOGIN, LOGIN, null);
}

@Test
public void authenticate_using_headers_defined_in_settings() throws Exception {
enableSsoAndSimulateNewUser();
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");
defineHeaders(ImmutableMap.of("head-login", LOGIN, "head-name", NAME, "head-email", EMAIL, "head-groups", GROUPS));

underTest.authenticate(request, response);

verifyUser(LOGIN, NAME, EMAIL, "dev", "admin");
}

@Test
public void trim_groups() throws Exception {
enableSsoAndSimulateNewUser();
defineHeadersFromDefaultValues(LOGIN, null, null, " dev , admin ");

underTest.authenticate(request, response);

verifyUser(LOGIN, LOGIN, null, "dev", "admin");
}

@Test
public void verify_identity_provider() throws Exception {
enableSsoAndSimulateNewUser();
defineHeadersFromDefaultValues(LOGIN, NAME, EMAIL, GROUPS);

underTest.authenticate(request, response);

verify(userIdentityAuthenticator).authenticate(any(UserIdentity.class), identityProviderCaptor.capture());
assertThat(identityProviderCaptor.getValue().getKey()).isEqualTo("sonarqube");
assertThat(identityProviderCaptor.getValue().getName()).isEqualTo("sonarqube");
assertThat(identityProviderCaptor.getValue().getDisplay()).isNull();
assertThat(identityProviderCaptor.getValue().isEnabled()).isTrue();
assertThat(identityProviderCaptor.getValue().allowsUsersToSignUp()).isTrue();
}

@Test
public void does_not_authenticate_when_no_header() throws Exception {
enableSsoAndSimulateNewUser();

underTest.authenticate(request, response);

verifyZeroInteractions(userIdentityAuthenticator, jwtHttpHandler);
}

@Test
public void does_not_authenticate_when_not_enabled() throws Exception {
settings.setProperty("sonar.sso.enable", false);

underTest.authenticate(request, response);

verifyZeroInteractions(userIdentityAuthenticator, jwtHttpHandler);
}

private void enableSsoAndSimulateNewUser() {
settings.setProperty("sonar.sso.enable", true);
when(userIdentityAuthenticator.authenticate(any(UserIdentity.class), any(IdentityProvider.class))).thenReturn(USER);
when(jwtHttpHandler.validateToken(request, response)).thenReturn(Optional.of(USER));
}

private void defineHeadersFromDefaultValues(String login, @Nullable String name, @Nullable String email, @Nullable String groups) {
Map<String, String> valuesByName = new HashMap<>();
valuesByName.put("X-Forwarded-Login", login);
if (name != null) {
valuesByName.put("X-Forwarded-Name", name);
}
if (email != null) {
valuesByName.put("X-Forwarded-Email", email);
}
if (groups != null) {
valuesByName.put("X-Forwarded-Groups", groups);
}
defineHeaders(valuesByName);
}

private void defineHeaders(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 verifyUser(String expectedLogin, String expectedName, @Nullable String expectedEmail, String... expectedGroups) {
verify(userIdentityAuthenticator).authenticate(userIdentityCaptor.capture(), any(IdentityProvider.class));
assertThat(userIdentityCaptor.getValue().getLogin()).isEqualTo(expectedLogin);
assertThat(userIdentityCaptor.getValue().getName()).isEqualTo(expectedName);
assertThat(userIdentityCaptor.getValue().getEmail()).isEqualTo(expectedEmail);
assertThat(userIdentityCaptor.getValue().getGroups()).containsOnly(expectedGroups);
}

}

+ 23
- 2
server/sonar-server/src/test/java/org/sonar/server/authentication/UserSessionInitializerTest.java View File

@@ -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();

+ 27
- 0
sonar-application/src/main/assembly/conf/sonar.properties View File

@@ -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


Loading…
Cancel
Save