@@ -230,6 +230,9 @@ Authentication will be tried on each server, in the order they are listed in the | |||
Note that all the LDAP servers must be available while (re)starting the SonarQube server. | |||
### Migrate users to a new authentication method | |||
If you are changing your delegated authentication method and migrating existing users from your previous authentication method, you can use the `api/users/update_identity_provider` web API to update your users' identity provider. | |||
### Troubleshooting | |||
* Detailed connection logs (and potential error codes received from LDAP server) are output to SonarQube's _$SONARQUBE_HOME/logs/web.log_, when logging is in `DEBUG` mode. | |||
@@ -0,0 +1,193 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2020 SonarSource SA | |||
* mailto:info 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.user.ws; | |||
import java.util.List; | |||
import java.util.stream.Collectors; | |||
import javax.annotation.Nullable; | |||
import org.sonar.api.server.authentication.IdentityProvider; | |||
import org.sonar.api.server.ws.Request; | |||
import org.sonar.api.server.ws.Response; | |||
import org.sonar.api.server.ws.WebService; | |||
import org.sonar.api.server.ws.WebService.NewController; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.DbSession; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.server.authentication.IdentityProviderRepository; | |||
import org.sonar.server.exceptions.NotFoundException; | |||
import org.sonar.server.user.ExternalIdentity; | |||
import org.sonar.server.user.UpdateUser; | |||
import org.sonar.server.user.UserSession; | |||
import org.sonar.server.user.UserUpdater; | |||
import static com.google.common.base.Preconditions.checkArgument; | |||
import static com.google.common.base.Strings.isNullOrEmpty; | |||
import static java.lang.String.format; | |||
import static org.sonar.server.user.ExternalIdentity.SQ_AUTHORITY; | |||
import static org.sonarqube.ws.client.user.UsersWsParameters.ACTION_UPDATE_IDENTITY_PROVIDER; | |||
import static org.sonarqube.ws.client.user.UsersWsParameters.PARAM_LOGIN; | |||
import static org.sonarqube.ws.client.user.UsersWsParameters.PARAM_NEW_EXTERNAL_IDENTITY; | |||
import static org.sonarqube.ws.client.user.UsersWsParameters.PARAM_NEW_EXTERNAL_PROVIDER; | |||
public class UpdateIdentityProviderAction implements UsersWsAction { | |||
private final DbClient dbClient; | |||
private final IdentityProviderRepository identityProviderRepository; | |||
private final UserUpdater userUpdater; | |||
private final UserSession userSession; | |||
public UpdateIdentityProviderAction(DbClient dbClient, IdentityProviderRepository identityProviderRepository, | |||
UserUpdater userUpdater, UserSession userSession) { | |||
this.dbClient = dbClient; | |||
this.identityProviderRepository = identityProviderRepository; | |||
this.userUpdater = userUpdater; | |||
this.userSession = userSession; | |||
} | |||
@Override | |||
public void define(NewController controller) { | |||
WebService.NewAction action = controller.createAction(ACTION_UPDATE_IDENTITY_PROVIDER) | |||
.setDescription("Update identity provider information. <br/>" | |||
+ "It's only possible to migrate to an installed identity provider. " | |||
+ "Be careful that as soon as this information has been updated for a user, " | |||
+ "the user will only be able to authenticate on the new identity provider. " | |||
+ "It is not possible to migrate external user to local one.<br/>" | |||
+ "Requires Administer System permission.") | |||
.setSince("8.7") | |||
.setInternal(false) | |||
.setPost(true) | |||
.setHandler(this); | |||
action.createParam(PARAM_LOGIN) | |||
.setDescription("User login") | |||
.setRequired(true); | |||
action.createParam(PARAM_NEW_EXTERNAL_PROVIDER) | |||
.setRequired(true) | |||
.setDescription("New external provider. Only authentication system installed are available. Use 'sonarqube' identity provider for LDAP."); | |||
action.createParam(PARAM_NEW_EXTERNAL_IDENTITY) | |||
.setDescription("New external identity, usually the login used in the authentication system. " | |||
+ "If not provided previous identity will be used."); | |||
} | |||
@Override | |||
public void handle(Request request, Response response) throws Exception { | |||
userSession.checkLoggedIn().checkIsSystemAdministrator(); | |||
UpdateIdentityProviderRequest wsRequest = toWsRequest(request); | |||
doHandle(wsRequest); | |||
response.noContent(); | |||
} | |||
private void doHandle(UpdateIdentityProviderRequest request) { | |||
checkEnabledIdentityProviders(request.newExternalProvider); | |||
try (DbSession dbSession = dbClient.openSession(false)) { | |||
UserDto user = getUser(dbSession, request.login); | |||
ExternalIdentity externalIdentity = getExternalIdentity(request, user); | |||
userUpdater.updateAndCommit(dbSession, user, new UpdateUser().setExternalIdentity(externalIdentity), u -> { | |||
}); | |||
} | |||
} | |||
private void checkEnabledIdentityProviders(String newExternalProvider) { | |||
List<String> availableIdentityProviders = getAvailableIdentityProviders(); | |||
checkArgument(availableIdentityProviders.contains(newExternalProvider), "Value of parameter 'newExternalProvider' (%s) must be one of: [%s]", newExternalProvider, | |||
String.join(", ", availableIdentityProviders)); | |||
} | |||
private List<String> getAvailableIdentityProviders() { | |||
List<String> discoveredProviders = identityProviderRepository.getAllEnabledAndSorted() | |||
.stream() | |||
.map(IdentityProvider::getKey) | |||
.collect(Collectors.toList()); | |||
discoveredProviders.add(SQ_AUTHORITY); | |||
return discoveredProviders; | |||
} | |||
private UserDto getUser(DbSession dbSession, String login) { | |||
UserDto user = dbClient.userDao().selectByLogin(dbSession, login); | |||
if (user == null || !user.isActive()) { | |||
throw new NotFoundException(format("User '%s' doesn't exist", login)); | |||
} | |||
return user; | |||
} | |||
private static ExternalIdentity getExternalIdentity(UpdateIdentityProviderRequest request, UserDto user) { | |||
return new ExternalIdentity( | |||
request.newExternalProvider, | |||
request.newExternalIdentity != null ? request.newExternalIdentity : user.getExternalLogin(), | |||
null); | |||
} | |||
private static UpdateIdentityProviderRequest toWsRequest(Request request) { | |||
return UpdateIdentityProviderRequest.builder() | |||
.setLogin(request.mandatoryParam(PARAM_LOGIN)) | |||
.setNewExternalProvider(request.mandatoryParam(PARAM_NEW_EXTERNAL_PROVIDER)) | |||
.setNewExternalIdentity(request.param(PARAM_NEW_EXTERNAL_IDENTITY)) | |||
.build(); | |||
} | |||
static class UpdateIdentityProviderRequest { | |||
private final String login; | |||
private final String newExternalProvider; | |||
private final String newExternalIdentity; | |||
public UpdateIdentityProviderRequest(Builder builder) { | |||
this.login = builder.login; | |||
this.newExternalProvider = builder.newExternalProvider; | |||
this.newExternalIdentity = builder.newExternalIdentity; | |||
} | |||
public static UpdateIdentityProviderRequest.Builder builder() { | |||
return new UpdateIdentityProviderRequest.Builder(); | |||
} | |||
static class Builder { | |||
private String login; | |||
private String newExternalProvider; | |||
private String newExternalIdentity; | |||
private Builder() { | |||
// enforce factory method use | |||
} | |||
public Builder setLogin(String login) { | |||
this.login = login; | |||
return this; | |||
} | |||
public Builder setNewExternalProvider(String newExternalProvider) { | |||
this.newExternalProvider = newExternalProvider; | |||
return this; | |||
} | |||
public Builder setNewExternalIdentity(@Nullable String newExternalIdentity) { | |||
this.newExternalIdentity = newExternalIdentity; | |||
return this; | |||
} | |||
public UpdateIdentityProviderRequest build() { | |||
checkArgument(!isNullOrEmpty(login), "Login is mandatory and must not be empty"); | |||
checkArgument(!isNullOrEmpty(newExternalProvider), "New External Provider is mandatory and must not be empty"); | |||
return new UpdateIdentityProviderRequest(this); | |||
} | |||
} | |||
} | |||
} |
@@ -46,7 +46,8 @@ public class UsersWsModule extends Module { | |||
UserJsonWriter.class, | |||
SetHomepageAction.class, | |||
HomepageTypesImpl.class, | |||
SetSettingAction.class); | |||
SetSettingAction.class, | |||
UpdateIdentityProviderAction.class); | |||
if (configuration.getBoolean(ProcessProperties.Property.SONARCLOUD_ENABLED.getKey()).orElse(false)) { | |||
// onboarding tutorial is available only in SonarCloud |
@@ -0,0 +1,189 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2020 SonarSource SA | |||
* mailto:info 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.user.ws; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.sonar.api.config.internal.MapSettings; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.DbSession; | |||
import org.sonar.db.DbTester; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.server.authentication.CredentialsLocalAuthentication; | |||
import org.sonar.server.authentication.IdentityProviderRepositoryRule; | |||
import org.sonar.server.authentication.TestIdentityProvider; | |||
import org.sonar.server.es.EsTester; | |||
import org.sonar.server.exceptions.ForbiddenException; | |||
import org.sonar.server.exceptions.NotFoundException; | |||
import org.sonar.server.exceptions.UnauthorizedException; | |||
import org.sonar.server.tester.UserSessionRule; | |||
import org.sonar.server.user.NewUserNotifier; | |||
import org.sonar.server.user.UserUpdater; | |||
import org.sonar.server.user.index.UserIndexer; | |||
import org.sonar.server.usergroups.DefaultGroupFinder; | |||
import org.sonar.server.ws.TestRequest; | |||
import org.sonar.server.ws.WsActionTester; | |||
import static com.google.common.collect.Lists.newArrayList; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | |||
import static org.mockito.Mockito.mock; | |||
import static org.sonar.db.user.UserTesting.newUserDto; | |||
public class UpdateIdentityProviderActionTest { | |||
private final static String SQ_AUTHORITY = "sonarqube"; | |||
@Rule | |||
public IdentityProviderRepositoryRule identityProviderRepository = new IdentityProviderRepositoryRule() | |||
.addIdentityProvider(new TestIdentityProvider().setName("Gitlab").setKey("gitlab").setEnabled(true)) | |||
.addIdentityProvider(new TestIdentityProvider().setName("Github").setKey("github").setEnabled(true)); | |||
@Rule | |||
public DbTester db = DbTester.create(); | |||
@Rule | |||
public EsTester es = EsTester.create(); | |||
@Rule | |||
public UserSessionRule userSession = UserSessionRule.standalone().logIn().setSystemAdministrator(); | |||
private final MapSettings settings = new MapSettings(); | |||
private final DbClient dbClient = db.getDbClient(); | |||
private final DbSession dbSession = db.getSession(); | |||
private final UserIndexer userIndexer = new UserIndexer(dbClient, es.client()); | |||
private final CredentialsLocalAuthentication localAuthentication = new CredentialsLocalAuthentication(dbClient); | |||
private final WsActionTester underTest = new WsActionTester(new UpdateIdentityProviderAction(dbClient, identityProviderRepository, | |||
new UserUpdater(mock(NewUserNotifier.class), dbClient, userIndexer, new DefaultGroupFinder(db.getDbClient()), settings.asConfig(), localAuthentication), | |||
userSession)); | |||
@Test | |||
public void change_identity_provider_of_a_local_user_all_params() { | |||
String userLogin = "login-1"; | |||
String newExternalLogin = "login@github.com"; | |||
String newExternalIdentityProvider = "github"; | |||
createUser(true, userLogin, userLogin, SQ_AUTHORITY); | |||
TestRequest request = underTest.newRequest() | |||
.setParam("login", userLogin) | |||
.setParam("newExternalProvider", newExternalIdentityProvider) | |||
.setParam("newExternalIdentity", newExternalLogin); | |||
request.execute(); | |||
assertThat(dbClient.userDao().selectByExternalLoginAndIdentityProvider(dbSession, newExternalLogin, newExternalIdentityProvider)) | |||
.isNotNull() | |||
.extracting(UserDto::isLocal, UserDto::getExternalLogin, UserDto::getExternalIdentityProvider) | |||
.contains(false, newExternalLogin, newExternalIdentityProvider); | |||
} | |||
@Test | |||
public void change_identity_provider_of_a_local_user_mandatory_params_only_provider_login_stays_same() { | |||
String userLogin = "login-1"; | |||
String newExternalIdentityProvider = "github"; | |||
createUser(true, userLogin, userLogin, SQ_AUTHORITY); | |||
TestRequest request = underTest.newRequest() | |||
.setParam("login", userLogin) | |||
.setParam("newExternalProvider", newExternalIdentityProvider); | |||
request.execute(); | |||
assertThat(dbClient.userDao().selectByExternalLoginAndIdentityProvider(dbSession, userLogin, newExternalIdentityProvider)) | |||
.isNotNull() | |||
.extracting(UserDto::isLocal, UserDto::getExternalLogin, UserDto::getExternalIdentityProvider) | |||
.contains(false, userLogin, newExternalIdentityProvider); | |||
} | |||
@Test | |||
public void change_identity_provider_of_a_external_user_to_new_one() { | |||
String userLogin = "login-1"; | |||
String oldExternalIdentityProvider = "gitlab"; | |||
String oldExternalIdentity = "john@gitlab.com"; | |||
createUser(false, userLogin, oldExternalIdentity, oldExternalIdentityProvider); | |||
String newExternalIdentityProvider = "github"; | |||
String newExternalIdentity = "john@github.com"; | |||
TestRequest request = underTest.newRequest() | |||
.setParam("login", userLogin) | |||
.setParam("newExternalProvider", newExternalIdentityProvider) | |||
.setParam("newExternalIdentity", newExternalIdentity); | |||
request.execute(); | |||
assertThat(dbClient.userDao().selectByExternalLoginAndIdentityProvider(dbSession, newExternalIdentity, newExternalIdentityProvider)) | |||
.isNotNull() | |||
.extracting(UserDto::isLocal, UserDto::getExternalLogin, UserDto::getExternalIdentityProvider) | |||
.contains(false, newExternalIdentity, newExternalIdentityProvider); | |||
} | |||
@Test | |||
public void fail_if_user_not_exist() { | |||
TestRequest request = underTest.newRequest() | |||
.setParam("login", "not-existing") | |||
.setParam("newExternalProvider", "gitlab"); | |||
assertThatThrownBy(request::execute) | |||
.isInstanceOf(NotFoundException.class) | |||
.hasMessage("User 'not-existing' doesn't exist"); | |||
} | |||
@Test | |||
public void fail_if_identity_provider_not_exist() { | |||
createUser(true, "login-1", "login-1", SQ_AUTHORITY); | |||
TestRequest request = underTest.newRequest() | |||
.setParam("login", "login-1") | |||
.setParam("newExternalProvider", "not-existing"); | |||
assertThatThrownBy(request::execute) | |||
.isInstanceOf(IllegalArgumentException.class) | |||
.hasMessage("Value of parameter 'newExternalProvider' (not-existing) must be one of: [github, gitlab, sonarqube]"); | |||
} | |||
@Test | |||
public void fail_if_anonymous() { | |||
userSession.anonymous(); | |||
TestRequest request = underTest.newRequest() | |||
.setParam("login", "not-existing") | |||
.setParam("newExternalProvider", "something"); | |||
assertThatThrownBy(request::execute) | |||
.isInstanceOf(UnauthorizedException.class); | |||
} | |||
@Test | |||
public void fail_if_not_admin() { | |||
userSession.logIn("some-user"); | |||
TestRequest request = underTest.newRequest() | |||
.setParam("login", "not-existing") | |||
.setParam("newExternalProvider", "something"); | |||
assertThatThrownBy(request::execute) | |||
.isInstanceOf(ForbiddenException.class); | |||
} | |||
private void createUser(boolean local, String login, String externalLogin, String externalIdentityProvider) { | |||
UserDto userDto = newUserDto() | |||
.setEmail("john@email.com") | |||
.setLogin(login) | |||
.setName("John") | |||
.setScmAccounts(newArrayList("jn")) | |||
.setActive(true) | |||
.setLocal(local) | |||
.setExternalLogin(externalLogin) | |||
.setExternalIdentityProvider(externalIdentityProvider); | |||
dbClient.userDao().insert(dbSession, userDto); | |||
userIndexer.commitAndIndex(dbSession, userDto); | |||
} | |||
} |
@@ -34,7 +34,7 @@ public class UsersWsModuleTest { | |||
public void verify_count_of_added_components() { | |||
ComponentContainer container = new ComponentContainer(); | |||
new UsersWsModule(new ConfigurationBridge(settings)).configure(container); | |||
assertThat(container.size()).isEqualTo(2 + 14); | |||
assertThat(container.size()).isEqualTo(2 + 15); | |||
} | |||
@Test | |||
@@ -43,6 +43,6 @@ public class UsersWsModuleTest { | |||
ComponentContainer container = new ComponentContainer(); | |||
new UsersWsModule(new ConfigurationBridge(settings)).configure(container); | |||
assertThat(container.size()).isEqualTo(2 + 15); | |||
assertThat(container.size()).isEqualTo(2 + 16); | |||
} | |||
} |
@@ -43,6 +43,7 @@ public class ApiDefinitionDownloader { | |||
Orchestrator orchestrator = builder | |||
// Enable organizations ws | |||
.setServerProperty("sonar.sonarcloud.enabled", "true") | |||
.setServerProperty("sonar.forceAuthentication", "false") | |||
.build(); | |||
orchestrator.start(); |
@@ -27,9 +27,9 @@ public class UsersWsParameters { | |||
public static final String ACTION_CREATE = "create"; | |||
public static final String ACTION_DEACTIVATE = "deactivate"; | |||
public static final String ACTION_UPDATE = "update"; | |||
public static final String ACTION_GROUPS = "groups"; | |||
public static final String ACTION_SKIP_ONBOARDING_TUTORIAL = "skip_onboarding_tutorial"; | |||
public static final String ACTION_CURRENT = "current"; | |||
public static final String ACTION_UPDATE_IDENTITY_PROVIDER = "update_identity_provider"; | |||
public static final String PARAM_LOGIN = "login"; | |||
public static final String PARAM_PASSWORD = "password"; | |||
@@ -40,6 +40,8 @@ public class UsersWsParameters { | |||
public static final String PARAM_SCM_ACCOUNT = "scmAccount"; | |||
public static final String PARAM_LOCAL = "local"; | |||
public static final String PARAM_SELECTED = "selected"; | |||
public static final String PARAM_NEW_EXTERNAL_PROVIDER = "newExternalProvider"; | |||
public static final String PARAM_NEW_EXTERNAL_IDENTITY = "newExternalIdentity"; | |||
private UsersWsParameters() { | |||
// Only static stuff |
@@ -0,0 +1,72 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2020 SonarSource SA | |||
* mailto:info 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.sonarqube.ws.client.users; | |||
import java.util.List; | |||
import javax.annotation.Generated; | |||
/** | |||
* This is part of the internal API. | |||
* This is a POST request. | |||
* @see <a href="https://next.sonarqube.com/sonarqube/web_api/api/users/update_identity_provider">Further information about this action online (including a response example)</a> | |||
* @since 8.7 | |||
*/ | |||
@Generated("sonar-ws-generator") | |||
public class UpdateIdentityProviderRequest { | |||
private String login; | |||
private String newExternalIdentity; | |||
private String newExternalProvider; | |||
/** | |||
* This is a mandatory parameter. | |||
*/ | |||
public UpdateIdentityProviderRequest setLogin(String login) { | |||
this.login = login; | |||
return this; | |||
} | |||
public String getLogin() { | |||
return login; | |||
} | |||
/** | |||
*/ | |||
public UpdateIdentityProviderRequest setNewExternalIdentity(String newExternalIdentity) { | |||
this.newExternalIdentity = newExternalIdentity; | |||
return this; | |||
} | |||
public String getNewExternalIdentity() { | |||
return newExternalIdentity; | |||
} | |||
/** | |||
* This is a mandatory parameter. | |||
*/ | |||
public UpdateIdentityProviderRequest setNewExternalProvider(String newExternalProvider) { | |||
this.newExternalProvider = newExternalProvider; | |||
return this; | |||
} | |||
public String getNewExternalProvider() { | |||
return newExternalProvider; | |||
} | |||
} |
@@ -227,4 +227,21 @@ public class UsersService extends BaseService { | |||
.setParam("newLogin", request.getNewLogin()) | |||
.setMediaType(MediaTypes.JSON)).content(); | |||
} | |||
/** | |||
* | |||
* This is part of the internal API. | |||
* This is a POST request. | |||
* @see <a href="https://next.sonarqube.com/sonarqube/web_api/api/users/update_identity_provider">Further information about this action online (including a response example)</a> | |||
* @since 8.7 | |||
*/ | |||
public void updateIdentityProvider(UpdateIdentityProviderRequest request) { | |||
call( | |||
new PostRequest(path("update_identity_provider")) | |||
.setParam("login", request.getLogin()) | |||
.setParam("newExternalIdentity", request.getNewExternalIdentity()) | |||
.setParam("newExternalProvider", request.getNewExternalProvider()) | |||
.setMediaType(MediaTypes.JSON) | |||
).content(); | |||
} | |||
} |