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