From: Teryk Bellahsene Date: Mon, 23 Nov 2015 15:55:20 +0000 (+0100) Subject: SONAR-7054 Token based authentication X-Git-Tag: 5.3-RC1~182 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=refs%2Fpull%2F650%2Fhead;p=sonarqube.git SONAR-7054 Token based authentication --- diff --git a/it/it-tests/src/test/java/it/Category1Suite.java b/it/it-tests/src/test/java/it/Category1Suite.java index 6896d5b070d..bc011b61cfd 100644 --- a/it/it-tests/src/test/java/it/Category1Suite.java +++ b/it/it-tests/src/test/java/it/Category1Suite.java @@ -41,7 +41,9 @@ package it;/* import com.sonar.orchestrator.Orchestrator; import it.actionPlan.ActionPlanTest; import it.actionPlan.ActionPlanUiTest; +import it.authorisation.AuthenticationTest; import it.authorisation.IssuePermissionTest; +import it.authorisation.PermissionTest; import it.i18n.I18nTest; import it.measureHistory.DifferentialPeriodsTest; import it.measureHistory.HistoryUiTest; @@ -85,6 +87,8 @@ import static util.ItUtils.xooPlugin; QualityGateUiTest.class, QualityGateNotificationTest.class, // permission + AuthenticationTest.class, + PermissionTest.class, IssuePermissionTest.class, // measure history DifferentialPeriodsTest.class, diff --git a/it/it-tests/src/test/java/it/authorisation/AuthenticationTest.java b/it/it-tests/src/test/java/it/authorisation/AuthenticationTest.java new file mode 100644 index 00000000000..30d0bdd5043 --- /dev/null +++ b/it/it-tests/src/test/java/it/authorisation/AuthenticationTest.java @@ -0,0 +1,203 @@ +/* + * SonarQube, open source software quality management tool. + * Copyright (C) 2008-2014 SonarSource + * mailto:contact AT sonarsource DOT com + * + * SonarQube 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. + * + * SonarQube 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.authorisation; + +import com.sonar.orchestrator.Orchestrator; +import com.sonar.orchestrator.build.BuildResult; +import com.sonar.orchestrator.build.SonarRunner; +import com.sonar.orchestrator.locator.FileLocation; +import it.Category1Suite; +import java.util.UUID; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Ignore; +import org.junit.Test; +import org.sonarqube.ws.WsUserTokens; +import org.sonarqube.ws.client.WsClient; +import org.sonarqube.ws.client.permission.AddGroupWsRequest; +import org.sonarqube.ws.client.permission.AddUserWsRequest; +import org.sonarqube.ws.client.permission.RemoveGroupWsRequest; +import org.sonarqube.ws.client.usertoken.GenerateWsRequest; +import org.sonarqube.ws.client.usertoken.UserTokensWsClient; + +import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThat; +import static org.sonarqube.ws.client.HttpConnector.newHttpConnector; +import static org.sonarqube.ws.client.WsRequest.newGetRequest; +import static org.sonarqube.ws.client.WsRequest.newPostRequest; +import static util.ItUtils.newAdminWsClient; +import static util.ItUtils.projectDir; + +public class AuthenticationTest { + @ClassRule + public static Orchestrator ORCHESTRATOR = Category1Suite.ORCHESTRATOR; + private static WsClient adminWsClient; + private static UserTokensWsClient userTokensWsClient; + + private static final String PROJECT_KEY = "sample"; + private static final String LOGIN = "george.orwell"; + + @BeforeClass + public static void setUp() { + ORCHESTRATOR.resetData(); + ORCHESTRATOR.getServer().restoreProfile(FileLocation.ofClasspath("/authorisation/one-issue-per-line-profile.xml")); + ORCHESTRATOR.getServer().provisionProject(PROJECT_KEY, "Sample"); + ORCHESTRATOR.getServer().associateProjectToQualityProfile("sample", "xoo", "one-issue-per-line"); + + adminWsClient = newAdminWsClient(ORCHESTRATOR); + userTokensWsClient = adminWsClient.userTokensWsClient(); + removeGroupPermission("anyone", "dryRunScan"); + removeGroupPermission("anyone", "scan"); + + createUser(LOGIN, "123456"); + addUserPermission(LOGIN, "admin"); + addUserPermission(LOGIN, "scan"); + } + + @AfterClass + public static void delete_data() { + deactivateUser(LOGIN); + addGroupPermission("anyone", "dryRunScan"); + addGroupPermission("anyone", "scan"); + } + + @Test + public void basic_authentication_based_on_login_and_password() { + String userId = UUID.randomUUID().toString(); + String login = format("login-%s", userId); + String name = format("name-%s", userId); + String password = "!ascii-only:-)@"; + createUser(login, name, password); + + // authenticate + WsClient wsClient = new WsClient(newHttpConnector().url(ORCHESTRATOR.getServer().getUrl()).login(login).password(password).build()); + String response = wsClient.execute(newGetRequest("api/authentication/validate")); + assertThat(response).isEqualTo("{\"valid\":true}"); + } + + @Test + public void basic_authentication_based_on_token() { + WsUserTokens.GenerateWsResponse generateWsResponse = userTokensWsClient.generate(new GenerateWsRequest() + .setLogin(LOGIN) + .setName("Validate token based authentication")); + WsClient wsClient = new WsClient(newHttpConnector() + .url(ORCHESTRATOR.getServer().getUrl()) + .login(generateWsResponse.getToken()) + .password("").build()); + + String response = wsClient.execute(newGetRequest("api/authentication/validate")); + + assertThat(response).isEqualTo("{\"valid\":true}"); + } + + /** + * This is currently a limitation of Ruby on Rails stack. + */ + @Test + public void basic_authentication_does_not_support_utf8_passwords() { + String userId = UUID.randomUUID().toString(); + String login = format("login-%s", userId); + // see http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt + String password = "κόσμε"; + + // create user with a UTF-8 password + createUser(login, format("name-%s", userId), password); + + // authenticate + WsClient wsClient = new WsClient(newHttpConnector().url(ORCHESTRATOR.getServer().getUrl()).login(login).password(password).build()); + String response = wsClient.execute(newGetRequest("api/authentication/validate")); + assertThat(response).isEqualTo("{\"valid\":false}"); + } + + @Test + @Ignore + public void web_login_form_should_support_utf8_passwords() { + // TODO selenium + } + + @Test + public void run_analysis_with_token_authentication() { + WsUserTokens.GenerateWsResponse generateWsResponse = userTokensWsClient.generate(new GenerateWsRequest() + .setLogin(LOGIN) + .setName("Analyze Project")); + SonarRunner sampleProject = SonarRunner.create(projectDir("shared/xoo-sample")); + sampleProject.setProperties( + "sonar.login", generateWsResponse.getToken(), + "sonar.password", ""); + + BuildResult buildResult = ORCHESTRATOR.executeBuild(sampleProject); + + assertThat(buildResult.isSuccess()).isTrue(); + } + + @Test + public void run_analysis_with_incorrect_token() { + SonarRunner sampleProject = SonarRunner.create(projectDir("shared/xoo-sample")); + sampleProject.setProperties( + "sonar.login", "unknown-token", + "sonar.password", ""); + + BuildResult buildResult = ORCHESTRATOR.executeBuildQuietly(sampleProject); + + assertThat(buildResult.isSuccess()).isFalse(); + } + + private static void createUser(String login, String password) { + adminWsClient.execute( + newPostRequest("api/users/create") + .setParam("login", login) + .setParam("name", login) + .setParam("password", password)); + } + + private static void createUser(String login, String name, String password) { + adminWsClient.execute( + newPostRequest("api/users/create") + .setParam("login", login) + .setParam("name", name) + .setParam("password", password)); + } + + private static void addUserPermission(String login, String permission) { + adminWsClient.permissionsClient().addUser(new AddUserWsRequest() + .setLogin(login) + .setPermission(permission)); + } + + private static void deactivateUser(String login) { + adminWsClient.execute( + newPostRequest("api/users/deactivate") + .setParam("login", login)); + } + + private static void removeGroupPermission(String groupName, String permission) { + adminWsClient.permissionsClient().removeGroup(new RemoveGroupWsRequest() + .setGroupName(groupName) + .setPermission(permission)); + } + + private static void addGroupPermission(String groupName, String permission) { + adminWsClient.permissionsClient().addGroup(new AddGroupWsRequest() + .setGroupName(groupName) + .setPermission(permission)); + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/usertoken/TokenGenerator.java b/server/sonar-server/src/main/java/org/sonar/server/usertoken/TokenGenerator.java index 46d70784021..0fa1c559341 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/usertoken/TokenGenerator.java +++ b/server/sonar-server/src/main/java/org/sonar/server/usertoken/TokenGenerator.java @@ -27,6 +27,13 @@ public interface TokenGenerator { * subject to change in subsequent SonarQube versions. *
* Length does not exceed 40 characters (arbitrary value). + *
+ * The token is sent through the userid field (login) of HTTP Basic authentication, + * + * Basic authentication is used to authenticate users from tokens, so the + * constraints of userid field (login) must be respected. Basically the token + * must not contain colon character ":". + * */ String generate(); diff --git a/server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenAuthenticator.java b/server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenAuthenticator.java new file mode 100644 index 00000000000..4d2bb22be58 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenAuthenticator.java @@ -0,0 +1,55 @@ +/* + * SonarQube, open source software quality management tool. + * Copyright (C) 2008-2014 SonarSource + * mailto:contact AT sonarsource DOT com + * + * SonarQube 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. + * + * SonarQube 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.usertoken; + +import com.google.common.base.Optional; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.UserTokenDto; + +public class UserTokenAuthenticator { + private final TokenGenerator tokenGenerator; + private final DbClient dbClient; + + public UserTokenAuthenticator(TokenGenerator tokenGenerator, DbClient dbClient) { + this.tokenGenerator = tokenGenerator; + this.dbClient = dbClient; + } + + /** + * Returns the user login if the token hash is found, else {@code Optional.absent()}. + * The returned login is not validated. If database is corrupted (table USER_TOKENS badly purged + * for instance), then the login may not relate to a valid user. + */ + public Optional authenticate(String token) { + String tokenHash = tokenGenerator.hash(token); + DbSession dbSession = dbClient.openSession(false); + try { + Optional userToken = dbClient.userTokenDao().selectByTokenHash(dbSession, tokenHash); + if (userToken.isPresent()) { + return Optional.of(userToken.get().getLogin()); + } + return Optional.absent(); + } finally { + dbClient.closeSession(dbSession); + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenModule.java b/server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenModule.java index 373264f2018..2fd1753fe2c 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenModule.java +++ b/server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenModule.java @@ -30,6 +30,7 @@ public class UserTokenModule extends Module { add( UserTokensWs.class, GenerateAction.class, + UserTokenAuthenticator.class, TokenGeneratorImpl.class); } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/usertoken/TokenGeneratorImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/usertoken/TokenGeneratorImplTest.java index bce967c2a3a..88d75bf33ab 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/usertoken/TokenGeneratorImplTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/usertoken/TokenGeneratorImplTest.java @@ -39,6 +39,11 @@ public class TokenGeneratorImplTest { .hasSize(40); } + @Test + public void token_does_not_contain_colon() { + assertThat(underTest.generate()).doesNotContain(":"); + } + @Test public void hash_token() { String hash = underTest.hash("1234567890123456789012345678901234567890"); diff --git a/server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenAuthenticatorTest.java b/server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenAuthenticatorTest.java new file mode 100644 index 00000000000..1a291c4ff5b --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenAuthenticatorTest.java @@ -0,0 +1,75 @@ +/* + * SonarQube, open source software quality management tool. + * Copyright (C) 2008-2014 SonarSource + * mailto:contact AT sonarsource DOT com + * + * SonarQube 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. + * + * SonarQube 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.usertoken; + +import com.google.common.base.Optional; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.rules.ExpectedException; +import org.sonar.api.utils.System2; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.test.DbTests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.db.user.UserTokenTesting.newUserToken; + +@Category(DbTests.class) +public class UserTokenAuthenticatorTest { + static final String GRACE_HOPPER = "grace.hopper"; + static final String ADA_LOVELACE = "ada.lovelace"; + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Rule + public DbTester db = DbTester.create(System2.INSTANCE); + DbClient dbClient = db.getDbClient(); + DbSession dbSession = db.getSession(); + TokenGenerator tokenGenerator = mock(TokenGenerator.class); + + UserTokenAuthenticator underTest = new UserTokenAuthenticator(tokenGenerator, db.getDbClient()); + + @Test + public void return_login_when_token_hash_found_in_db() { + String token = "known-token"; + String tokenHash = "123456789"; + when(tokenGenerator.hash(token)).thenReturn(tokenHash); + dbClient.userTokenDao().insert(dbSession, newUserToken().setLogin(GRACE_HOPPER).setTokenHash(tokenHash)); + dbClient.userTokenDao().insert(dbSession, newUserToken().setLogin(ADA_LOVELACE).setTokenHash("another-token-hash")); + db.commit(); + + Optional login = underTest.authenticate(token); + + assertThat(login.isPresent()).isTrue(); + assertThat(login.get()).isEqualTo(GRACE_HOPPER); + } + + @Test + public void return_absent_if_token_hash_is_not_found() { + Optional login = underTest.authenticate("unknown-token"); + assertThat(login.isPresent()).isFalse(); + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenModuleTest.java b/server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenModuleTest.java index 91b9de139d6..e4bd3a614c5 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenModuleTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenModuleTest.java @@ -30,6 +30,6 @@ public class UserTokenModuleTest { public void verify_count_of_added_components() { ComponentContainer container = new ComponentContainer(); new UserTokenModule().configure(container); - assertThat(container.size()).isEqualTo(5); + assertThat(container.size()).isEqualTo(6); } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java index aad9cc18cf1..a4190e479ef 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java @@ -25,6 +25,7 @@ import java.io.InputStream; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.experimental.categories.Category; import org.junit.rules.ExpectedException; import org.sonar.api.utils.System2; import org.sonar.core.permission.GlobalPermissions; @@ -36,6 +37,7 @@ import org.sonar.server.exceptions.ServerException; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.usertoken.TokenGenerator; import org.sonar.server.ws.WsActionTester; +import org.sonar.test.DbTests; import org.sonarqube.ws.MediaTypes; import org.sonarqube.ws.WsUserTokens.GenerateWsResponse; @@ -49,6 +51,7 @@ import static org.sonar.test.JsonAssert.assertJson; import static org.sonarqube.ws.client.usertoken.UserTokensWsParameters.PARAM_LOGIN; import static org.sonarqube.ws.client.usertoken.UserTokensWsParameters.PARAM_NAME; +@Category(DbTests.class) public class GenerateActionTest { private static final String GRACE_HOPPER = "grace.hopper"; private static final String ADA_LOVELACE = "ada.lovelace"; diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/models/user.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/models/user.rb index fed2167f346..e6e3e2dc7cc 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/app/models/user.rb +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/models/user.rb @@ -62,6 +62,7 @@ class User < ActiveRecord::Base # prevents a user from submitting a crafted form that bypasses activation # anything else you want your user to change should be added here. attr_accessible :login, :email, :name, :password, :password_confirmation + attr_accessor :token_authenticated #### # As now dates are saved in long they should be no more automatically managed by Rails diff --git a/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb b/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb index 0ec3518fb2f..8f28272d4a0 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb +++ b/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb @@ -127,7 +127,24 @@ module AuthenticatedSystem # Called from #current_user. Now, attempt to login by basic authentication information. def login_from_basic_auth authenticate_with_http_basic do |login, password| - self.current_user = User.authenticate(login, password, servlet_request) + # The access token is sent as the login of Basic authentication. To distinguish with regular logins, + # the convention is that the password is empty + if password.empty? && login.present? + # authentication by access token + token_authenticator = Java::OrgSonarServerPlatform::Platform.component(Java::OrgSonarServerUsertoken::UserTokenAuthenticator.java_class) + authenticated_login = token_authenticator.authenticate(login) + if authenticated_login.isPresent() + user = User.find_active_by_login(authenticated_login.get()) + if user + user.token_authenticated=true + self.current_user = user + self.current_user + end + end + else + # regular Basic authentication with login and password + self.current_user = User.authenticate(login, password, servlet_request) + end end end