diff options
author | Julien Lancelot <julien.lancelot@sonarsource.com> | 2019-09-24 11:44:20 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-10-07 20:21:06 +0200 |
commit | 42f0cff638b6b7055acc6cf75bbb2215867d0474 (patch) | |
tree | 9c4bbdb045a1cabe844c14fdda2413c1b94a7fcb /server/sonar-auth-common | |
parent | daf5a60dd259039b97fd3598f894169d7ecc74e5 (diff) | |
download | sonarqube-42f0cff638b6b7055acc6cf75bbb2215867d0474.tar.gz sonarqube-42f0cff638b6b7055acc6cf75bbb2215867d0474.zip |
SONAR-12471 Embed GitHub authentication
Diffstat (limited to 'server/sonar-auth-common')
4 files changed, 270 insertions, 0 deletions
diff --git a/server/sonar-auth-common/build.gradle b/server/sonar-auth-common/build.gradle new file mode 100644 index 00000000000..9a669541dac --- /dev/null +++ b/server/sonar-auth-common/build.gradle @@ -0,0 +1,21 @@ +description = 'SonarQube :: Authentication :: Common' + +configurations { + testCompile.extendsFrom compileOnly +} + +dependencies { + // please keep the list ordered + + compile 'com.github.scribejava:scribejava-apis' + compile 'com.github.scribejava:scribejava-core' + + compileOnly 'com.google.code.findbugs:jsr305' + + testCompile 'commons-lang:commons-lang' + testCompile 'com.squareup.okhttp3:mockwebserver' + testCompile 'com.squareup.okhttp3:okhttp' + testCompile 'junit:junit' + testCompile 'org.assertj:assertj-core' + testCompile 'org.mockito:mockito-core' +} diff --git a/server/sonar-auth-common/src/main/java/org/sonar/auth/OAuthRestClient.java b/server/sonar-auth-common/src/main/java/org/sonar/auth/OAuthRestClient.java new file mode 100644 index 00000000000..f07b9e0b595 --- /dev/null +++ b/server/sonar-auth-common/src/main/java/org/sonar/auth/OAuthRestClient.java @@ -0,0 +1,99 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.auth; + +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.model.OAuthRequest; +import com.github.scribejava.core.model.Response; +import com.github.scribejava.core.model.Verb; +import com.github.scribejava.core.oauth.OAuth20Service; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.lang.String.format; + +public class OAuthRestClient { + + private static final int DEFAULT_PAGE_SIZE = 100; + private static final Pattern NEXT_LINK_PATTERN = Pattern.compile(".*<(.*)>; rel=\"next\""); + + private OAuthRestClient() { + // Only static method + } + + public static Response executeRequest(String requestUrl, OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException { + OAuthRequest request = new OAuthRequest(Verb.GET, requestUrl); + scribe.signRequest(accessToken, request); + try { + Response response = scribe.execute(request); + if (!response.isSuccessful()) { + throw unexpectedResponseCode(requestUrl, response); + } + return response; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (ExecutionException e) { + throw new IllegalStateException(e); + } + } + + public static <E> List<E> executePaginatedRequest(String request, OAuth20Service scribe, OAuth2AccessToken accessToken, Function<String, List<E>> function) { + List<E> result = new ArrayList<>(); + readPage(result, scribe, accessToken, request + "?per_page=" + DEFAULT_PAGE_SIZE, function); + return result; + } + + private static <E> void readPage(List<E> result, OAuth20Service scribe, OAuth2AccessToken accessToken, String endPoint, Function<String, List<E>> function) { + try (Response nextResponse = executeRequest(endPoint, scribe, accessToken)) { + String content = nextResponse.getBody(); + if (content == null) { + return; + } + result.addAll(function.apply(content)); + readNextEndPoint(nextResponse).ifPresent(newNextEndPoint -> readPage(result, scribe, accessToken, newNextEndPoint, function)); + } catch (IOException e) { + throw new IllegalStateException(format("Failed to get %s", endPoint), e); + } + } + + private static Optional<String> readNextEndPoint(Response response) { + String link = response.getHeader("Link"); + if (link == null || link.isEmpty() || !link.contains("rel=\"next\"")) { + return Optional.empty(); + } + Matcher nextLinkMatcher = NEXT_LINK_PATTERN.matcher(link); + if (!nextLinkMatcher.find()) { + return Optional.empty(); + } + return Optional.of(nextLinkMatcher.group(1)); + } + + private static IllegalStateException unexpectedResponseCode(String requestUrl, Response response) throws IOException { + return new IllegalStateException(format("Fail to execute request '%s'. HTTP code: %s, response: %s", requestUrl, response.getCode(), response.getBody())); + } + +} diff --git a/server/sonar-auth-common/src/main/java/org/sonar/auth/package-info.java b/server/sonar-auth-common/src/main/java/org/sonar/auth/package-info.java new file mode 100644 index 00000000000..00a16681355 --- /dev/null +++ b/server/sonar-auth-common/src/main/java/org/sonar/auth/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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. + */ +@ParametersAreNonnullByDefault +package org.sonar.auth; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-auth-common/src/test/java/org/sonar/auth/OAuthRestClientTest.java b/server/sonar-auth-common/src/test/java/org/sonar/auth/OAuthRestClientTest.java new file mode 100644 index 00000000000..7f1826e3e12 --- /dev/null +++ b/server/sonar-auth-common/src/test/java/org/sonar/auth/OAuthRestClientTest.java @@ -0,0 +1,127 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.auth; + +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.builder.api.DefaultApi20; +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.model.Response; +import com.github.scribejava.core.oauth.OAuth20Service; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static java.lang.String.format; +import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.sonar.auth.OAuthRestClient.executePaginatedRequest; +import static org.sonar.auth.OAuthRestClient.executeRequest; + +public class OAuthRestClientTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Rule + public MockWebServer mockWebServer = new MockWebServer(); + + private OAuth2AccessToken auth2AccessToken = mock(OAuth2AccessToken.class); + + private String serverUrl; + + private OAuth20Service oAuth20Service = new ServiceBuilder("API_KEY") + .apiSecret("API_SECRET") + .callback("CALLBACK") + .build(new TestAPI()); + + @Before + public void setUp() { + this.serverUrl = format("http://%s:%d", mockWebServer.getHostName(), mockWebServer.getPort()); + } + + @Test + public void execute_request() throws IOException { + String body = randomAlphanumeric(10); + mockWebServer.enqueue(new MockResponse().setBody(body)); + + Response response = executeRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken); + + assertThat(response.getBody()).isEqualTo(body); + } + + @Test + public void fail_to_execute_request() throws IOException { + mockWebServer.enqueue(new MockResponse().setResponseCode(404).setBody("Error!")); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage(format("Fail to execute request '%s/test'. HTTP code: 404, response: Error!", serverUrl)); + + executeRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken); + } + + @Test + public void execute_paginated_request() { + mockWebServer.enqueue(new MockResponse() + .setHeader("Link", "<" + serverUrl + "/test?per_page=100&page=2>; rel=\"next\", <" + serverUrl + "/test?per_page=100&page=2>; rel=\"last\"") + .setBody("A")); + mockWebServer.enqueue(new MockResponse() + .setHeader("Link", "<" + serverUrl + "/test?per_page=100&page=1>; rel=\"prev\", <" + serverUrl + "/test?per_page=100&page=1>; rel=\"first\"") + .setBody("B")); + + List<String> response = executePaginatedRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken, Arrays::asList); + + assertThat(response).contains("A", "B"); + } + + @Test + public void fail_to_executed_paginated_request() { + mockWebServer.enqueue(new MockResponse() + .setHeader("Link", "<" + serverUrl + "/test?per_page=100&page=2>; rel=\"next\", <" + serverUrl + "/test?per_page=100&page=2>; rel=\"last\"") + .setBody("A")); + mockWebServer.enqueue(new MockResponse().setResponseCode(404).setBody("Error!")); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage(format("Fail to execute request '%s/test?per_page=100&page=2'. HTTP code: 404, response: Error!", serverUrl)); + + executePaginatedRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken, Arrays::asList); + } + + private class TestAPI extends DefaultApi20 { + + @Override + public String getAccessTokenEndpoint() { + return serverUrl + "/login/oauth/access_token"; + } + + @Override + protected String getAuthorizationBaseUrl() { + return serverUrl + "/login/oauth/authorize"; + } + + } +} |