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' | |||||
} |
/* | |||||
* 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())); | |||||
} | |||||
} |
/* | |||||
* 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; |
/* | |||||
* 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"; | |||||
} | |||||
} | |||||
} |
description = 'SonarQube :: Authentication :: GitHub' | |||||
configurations { | |||||
testCompile.extendsFrom compileOnly | |||||
} | |||||
dependencies { | |||||
// please keep the list ordered | |||||
compile 'com.github.scribejava:scribejava-apis' | |||||
compile 'com.github.scribejava:scribejava-core' | |||||
compile 'com.google.code.gson:gson' | |||||
compile project(':server:sonar-auth-common') | |||||
compileOnly 'com.google.code.findbugs:jsr305' | |||||
compileOnly 'com.squareup.okhttp3:okhttp' | |||||
compileOnly 'javax.servlet:javax.servlet-api' | |||||
compileOnly project(':sonar-core') | |||||
compileOnly project(':sonar-ws') | |||||
testCompile 'com.squareup.okhttp3:mockwebserver' | |||||
testCompile 'com.squareup.okhttp3:okhttp' | |||||
testCompile 'junit:junit' | |||||
testCompile 'org.assertj:assertj-core' | |||||
testCompile 'org.mockito:mockito-core' | |||||
} |
/* | |||||
* 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.github; | |||||
import com.github.scribejava.core.builder.ServiceBuilder; | |||||
import com.github.scribejava.core.model.OAuth2AccessToken; | |||||
import com.github.scribejava.core.oauth.OAuth20Service; | |||||
import java.io.IOException; | |||||
import java.util.concurrent.ExecutionException; | |||||
import javax.servlet.http.HttpServletRequest; | |||||
import org.sonar.api.server.authentication.Display; | |||||
import org.sonar.api.server.authentication.OAuth2IdentityProvider; | |||||
import org.sonar.api.server.authentication.UnauthorizedException; | |||||
import org.sonar.api.server.authentication.UserIdentity; | |||||
import static com.google.common.base.Preconditions.checkState; | |||||
import static java.lang.String.format; | |||||
public class GitHubIdentityProvider implements OAuth2IdentityProvider { | |||||
static final String KEY = "github"; | |||||
private final GitHubSettings settings; | |||||
private final UserIdentityFactory userIdentityFactory; | |||||
private final ScribeGitHubApi scribeApi; | |||||
private final GitHubRestClient gitHubRestClient; | |||||
public GitHubIdentityProvider(GitHubSettings settings, UserIdentityFactory userIdentityFactory, ScribeGitHubApi scribeApi, GitHubRestClient gitHubRestClient) { | |||||
this.settings = settings; | |||||
this.userIdentityFactory = userIdentityFactory; | |||||
this.scribeApi = scribeApi; | |||||
this.gitHubRestClient = gitHubRestClient; | |||||
} | |||||
@Override | |||||
public String getKey() { | |||||
return KEY; | |||||
} | |||||
@Override | |||||
public String getName() { | |||||
return "GitHub"; | |||||
} | |||||
@Override | |||||
public Display getDisplay() { | |||||
return Display.builder() | |||||
.setIconPath("/images/github.svg") | |||||
.setBackgroundColor("#444444") | |||||
.build(); | |||||
} | |||||
@Override | |||||
public boolean isEnabled() { | |||||
return settings.isEnabled(); | |||||
} | |||||
@Override | |||||
public boolean allowsUsersToSignUp() { | |||||
return settings.allowUsersToSignUp(); | |||||
} | |||||
@Override | |||||
public void init(InitContext context) { | |||||
String state = context.generateCsrfState(); | |||||
OAuth20Service scribe = newScribeBuilder(context) | |||||
.defaultScope(getScope()) | |||||
.build(scribeApi); | |||||
String url = scribe.getAuthorizationUrl(state); | |||||
context.redirectTo(url); | |||||
} | |||||
String getScope() { | |||||
return (settings.syncGroups() || isOrganizationMembershipRequired()) ? "user:email,read:org" : "user:email"; | |||||
} | |||||
@Override | |||||
public void callback(CallbackContext context) { | |||||
try { | |||||
onCallback(context); | |||||
} catch (IOException | ExecutionException e) { | |||||
throw new IllegalStateException(e); | |||||
} catch (InterruptedException e) { | |||||
Thread.currentThread().interrupt(); | |||||
throw new IllegalStateException(e); | |||||
} | |||||
} | |||||
private void onCallback(CallbackContext context) throws InterruptedException, ExecutionException, IOException { | |||||
context.verifyCsrfState(); | |||||
HttpServletRequest request = context.getRequest(); | |||||
OAuth20Service scribe = newScribeBuilder(context).build(scribeApi); | |||||
String code = request.getParameter("code"); | |||||
OAuth2AccessToken accessToken = scribe.getAccessToken(code); | |||||
GsonUser user = gitHubRestClient.getUser(scribe, accessToken); | |||||
check(scribe, accessToken, user); | |||||
final String email; | |||||
if (user.getEmail() == null) { | |||||
// if the user has not specified a public email address in their profile | |||||
email = gitHubRestClient.getEmail(scribe, accessToken); | |||||
} else { | |||||
email = user.getEmail(); | |||||
} | |||||
UserIdentity userIdentity = userIdentityFactory.create(user, email, | |||||
settings.syncGroups() ? gitHubRestClient.getTeams(scribe, accessToken) : null); | |||||
context.authenticate(userIdentity); | |||||
context.redirectToRequestedPage(); | |||||
} | |||||
boolean isOrganizationMembershipRequired() { | |||||
return settings.organizations().length > 0; | |||||
} | |||||
private void check(OAuth20Service scribe, OAuth2AccessToken accessToken, GsonUser user) throws InterruptedException, ExecutionException, IOException { | |||||
if (isUnauthorized(scribe, accessToken, user.getLogin())) { | |||||
throw new UnauthorizedException(format("'%s' must be a member of at least one organization: '%s'", user.getLogin(), String.join("', '", settings.organizations()))); | |||||
} | |||||
} | |||||
private boolean isUnauthorized(OAuth20Service scribe, OAuth2AccessToken accessToken, String login) throws IOException, ExecutionException, InterruptedException { | |||||
return isOrganizationMembershipRequired() && !isOrganizationsMember(scribe, accessToken, login); | |||||
} | |||||
private boolean isOrganizationsMember(OAuth20Service scribe, OAuth2AccessToken accessToken, String login) throws IOException, ExecutionException, InterruptedException { | |||||
for (String organization : settings.organizations()) { | |||||
if (gitHubRestClient.isOrganizationMember(scribe, accessToken, organization, login)) { | |||||
return true; | |||||
} | |||||
} | |||||
return false; | |||||
} | |||||
private ServiceBuilder newScribeBuilder(OAuth2IdentityProvider.OAuth2Context context) { | |||||
checkState(isEnabled(), "GitHub authentication is disabled"); | |||||
return new ServiceBuilder(settings.clientId()) | |||||
.apiSecret(settings.clientSecret()) | |||||
.callback(context.getCallbackUrl()); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import java.util.List; | |||||
import org.sonar.api.config.PropertyDefinition; | |||||
import org.sonar.core.platform.Module; | |||||
import static org.sonar.auth.github.GitHubSettings.definitions; | |||||
public class GitHubModule extends Module { | |||||
@Override | |||||
protected void configureModule() { | |||||
add( | |||||
GitHubIdentityProvider.class, | |||||
GitHubSettings.class, | |||||
GitHubRestClient.class, | |||||
UserIdentityFactoryImpl.class, | |||||
ScribeGitHubApi.class); | |||||
List<PropertyDefinition> definitions = definitions(); | |||||
add(definitions.toArray(new Object[definitions.size()])); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
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.net.HttpURLConnection; | |||||
import java.util.List; | |||||
import java.util.concurrent.ExecutionException; | |||||
import org.sonar.api.utils.log.Logger; | |||||
import org.sonar.api.utils.log.Loggers; | |||||
import static java.lang.String.format; | |||||
import static org.sonar.auth.OAuthRestClient.executePaginatedRequest; | |||||
import static org.sonar.auth.OAuthRestClient.executeRequest; | |||||
public class GitHubRestClient { | |||||
private static final Logger LOGGER = Loggers.get(GitHubRestClient.class); | |||||
private final GitHubSettings settings; | |||||
public GitHubRestClient(GitHubSettings settings) { | |||||
this.settings = settings; | |||||
} | |||||
GsonUser getUser(OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException { | |||||
String responseBody = executeRequest(settings.apiURL() + "user", scribe, accessToken).getBody(); | |||||
LOGGER.trace("User response received : {}", responseBody); | |||||
return GsonUser.parse(responseBody); | |||||
} | |||||
String getEmail(OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException { | |||||
String responseBody = executeRequest(settings.apiURL() + "user/emails", scribe, accessToken).getBody(); | |||||
LOGGER.trace("Emails response received : {}", responseBody); | |||||
List<GsonEmail> emails = GsonEmail.parse(responseBody); | |||||
return emails.stream() | |||||
.filter(email -> email.isPrimary() && email.isVerified()) | |||||
.findFirst() | |||||
.map(GsonEmail::getEmail) | |||||
.orElse(null); | |||||
} | |||||
List<GsonTeam> getTeams(OAuth20Service scribe, OAuth2AccessToken accessToken) { | |||||
return executePaginatedRequest(settings.apiURL() + "user/teams", scribe, accessToken, GsonTeam::parse); | |||||
} | |||||
/** | |||||
* Check to see that login is a member of organization. | |||||
* | |||||
* A 204 response code indicates organization membership. 302 and 404 codes are not treated as exceptional, | |||||
* they indicate various ways in which a login is not a member of the organization. | |||||
* | |||||
* @see <a href="https://developer.github.com/v3/orgs/members/#response-if-requester-is-an-organization-member-and-user-is-a-member">GitHub members API</a> | |||||
*/ | |||||
boolean isOrganizationMember(OAuth20Service scribe, OAuth2AccessToken accessToken, String organization, String login) | |||||
throws IOException, ExecutionException, InterruptedException { | |||||
String requestUrl = settings.apiURL() + format("orgs/%s/members/%s", organization, login); | |||||
OAuthRequest request = new OAuthRequest(Verb.GET, requestUrl); | |||||
scribe.signRequest(accessToken, request); | |||||
Response response = scribe.execute(request); | |||||
int code = response.getCode(); | |||||
switch (code) { | |||||
case HttpURLConnection.HTTP_MOVED_TEMP: | |||||
case HttpURLConnection.HTTP_NOT_FOUND: | |||||
case HttpURLConnection.HTTP_NO_CONTENT: | |||||
LOGGER.trace("Orgs response received : {}", code); | |||||
return code == HttpURLConnection.HTTP_NO_CONTENT; | |||||
default: | |||||
throw unexpectedResponseCode(requestUrl, response); | |||||
} | |||||
} | |||||
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())); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import java.util.Arrays; | |||||
import java.util.List; | |||||
import javax.annotation.CheckForNull; | |||||
import javax.annotation.Nullable; | |||||
import org.sonar.api.config.Configuration; | |||||
import org.sonar.api.config.PropertyDefinition; | |||||
import static java.lang.String.format; | |||||
import static java.lang.String.valueOf; | |||||
import static org.sonar.api.PropertyType.BOOLEAN; | |||||
import static org.sonar.api.PropertyType.SINGLE_SELECT_LIST; | |||||
import static org.sonar.api.PropertyType.STRING; | |||||
public class GitHubSettings { | |||||
private static final String CLIENT_ID = "sonar.auth.github.clientId.secured"; | |||||
private static final String CLIENT_SECRET = "sonar.auth.github.clientSecret.secured"; | |||||
private static final String ENABLED = "sonar.auth.github.enabled"; | |||||
private static final String ALLOW_USERS_TO_SIGN_UP = "sonar.auth.github.allowUsersToSignUp"; | |||||
private static final String GROUPS_SYNC = "sonar.auth.github.groupsSync"; | |||||
private static final String API_URL = "sonar.auth.github.apiUrl"; | |||||
private static final String WEB_URL = "sonar.auth.github.webUrl"; | |||||
static final String LOGIN_STRATEGY = "sonar.auth.github.loginStrategy"; | |||||
static final String LOGIN_STRATEGY_UNIQUE = "Unique"; | |||||
static final String LOGIN_STRATEGY_PROVIDER_ID = "Same as GitHub login"; | |||||
static final String LOGIN_STRATEGY_DEFAULT_VALUE = LOGIN_STRATEGY_UNIQUE; | |||||
private static final String ORGANIZATIONS = "sonar.auth.github.organizations"; | |||||
private static final String CATEGORY = "security"; | |||||
private static final String SUBCATEGORY = "github"; | |||||
private final Configuration configuration; | |||||
public GitHubSettings(Configuration configuration) { | |||||
this.configuration = configuration; | |||||
} | |||||
String clientId() { | |||||
return configuration.get(CLIENT_ID).orElse(""); | |||||
} | |||||
String clientSecret() { | |||||
return configuration.get(CLIENT_SECRET).orElse(""); | |||||
} | |||||
boolean isEnabled() { | |||||
return configuration.getBoolean(ENABLED).orElse(false) && !clientId().isEmpty() && !clientSecret().isEmpty(); | |||||
} | |||||
boolean allowUsersToSignUp() { | |||||
return configuration.getBoolean(ALLOW_USERS_TO_SIGN_UP).orElse(false); | |||||
} | |||||
String loginStrategy() { | |||||
return configuration.get(LOGIN_STRATEGY).orElse(""); | |||||
} | |||||
boolean syncGroups() { | |||||
return configuration.getBoolean(GROUPS_SYNC).orElse(false); | |||||
} | |||||
@CheckForNull | |||||
String webURL() { | |||||
return urlWithEndingSlash(configuration.get(WEB_URL).orElse("")); | |||||
} | |||||
@CheckForNull | |||||
String apiURL() { | |||||
return urlWithEndingSlash(configuration.get(API_URL).orElse("")); | |||||
} | |||||
String[] organizations() { | |||||
return configuration.getStringArray(ORGANIZATIONS); | |||||
} | |||||
@CheckForNull | |||||
private static String urlWithEndingSlash(@Nullable String url) { | |||||
if (url != null && !url.endsWith("/")) { | |||||
return url + "/"; | |||||
} | |||||
return url; | |||||
} | |||||
public static List<PropertyDefinition> definitions() { | |||||
return Arrays.asList( | |||||
PropertyDefinition.builder(ENABLED) | |||||
.name("Enabled") | |||||
.description("Enable GitHub users to login. Value is ignored if client ID and secret are not defined.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.type(BOOLEAN) | |||||
.defaultValue(valueOf(false)) | |||||
.index(1) | |||||
.build(), | |||||
PropertyDefinition.builder(CLIENT_ID) | |||||
.name("Client ID") | |||||
.description("Client ID provided by GitHub when registering the application.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(2) | |||||
.build(), | |||||
PropertyDefinition.builder(CLIENT_SECRET) | |||||
.name("Client Secret") | |||||
.description("Client password provided by GitHub when registering the application.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(3) | |||||
.build(), | |||||
PropertyDefinition.builder(ALLOW_USERS_TO_SIGN_UP) | |||||
.name("Allow users to sign-up") | |||||
.description("Allow new users to authenticate. When set to 'false', only existing users will be able to authenticate to the server.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.type(BOOLEAN) | |||||
.defaultValue(valueOf(true)) | |||||
.index(4) | |||||
.build(), | |||||
PropertyDefinition.builder(LOGIN_STRATEGY) | |||||
.name("Login generation strategy") | |||||
.description(format("When the login strategy is set to '%s', the user's login will be auto-generated the first time so that it is unique. " + | |||||
"When the login strategy is set to '%s', the user's login will be the GitHub login.", | |||||
LOGIN_STRATEGY_UNIQUE, LOGIN_STRATEGY_PROVIDER_ID)) | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.type(SINGLE_SELECT_LIST) | |||||
.defaultValue(LOGIN_STRATEGY_DEFAULT_VALUE) | |||||
.options(LOGIN_STRATEGY_UNIQUE, LOGIN_STRATEGY_PROVIDER_ID) | |||||
.index(5) | |||||
.build(), | |||||
PropertyDefinition.builder(GROUPS_SYNC) | |||||
.name("Synchronize teams as groups") | |||||
.description("For each team he belongs to, the user will be associated to a group named 'Organisation/Team' (if it exists) in SonarQube.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.type(BOOLEAN) | |||||
.defaultValue(valueOf(false)) | |||||
.index(6) | |||||
.build(), | |||||
PropertyDefinition.builder(API_URL) | |||||
.name("The API url for a GitHub instance.") | |||||
.description("The API url for a GitHub instance. https://api.github.com/ for github.com, https://github.company.com/api/v3/ when using Github Enterprise") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.type(STRING) | |||||
.defaultValue(valueOf("https://api.github.com/")) | |||||
.index(7) | |||||
.build(), | |||||
PropertyDefinition.builder(WEB_URL) | |||||
.name("The WEB url for a GitHub instance.") | |||||
.description("The WEB url for a GitHub instance. " + | |||||
"https://github.com/ for github.com, https://github.company.com/ when using GitHub Enterprise.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.type(STRING) | |||||
.defaultValue(valueOf("https://github.com/")) | |||||
.index(8) | |||||
.build(), | |||||
PropertyDefinition.builder(ORGANIZATIONS) | |||||
.name("Organizations") | |||||
.description("Only members of these organizations will be able to authenticate to the server. " + | |||||
"If a user is a member of any of the organizations listed they will be authenticated.") | |||||
.multiValues(true) | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(9) | |||||
.build()); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import com.google.gson.Gson; | |||||
import com.google.gson.reflect.TypeToken; | |||||
import java.lang.reflect.Type; | |||||
import java.util.Collection; | |||||
import java.util.List; | |||||
/** | |||||
* Lite representation of JSON response of GET https://api.github.com/user/emails | |||||
*/ | |||||
public class GsonEmail { | |||||
private String email; | |||||
private boolean verified; | |||||
private boolean primary; | |||||
public GsonEmail() { | |||||
// http://stackoverflow.com/a/18645370/229031 | |||||
this("", false, false); | |||||
} | |||||
public GsonEmail(String email, boolean verified, boolean primary) { | |||||
this.email = email; | |||||
this.verified = verified; | |||||
this.primary = primary; | |||||
} | |||||
public String getEmail() { | |||||
return email; | |||||
} | |||||
public boolean isVerified() { | |||||
return verified; | |||||
} | |||||
public boolean isPrimary() { | |||||
return primary; | |||||
} | |||||
public static List<GsonEmail> parse(String json) { | |||||
Type collectionType = new TypeToken<Collection<GsonEmail>>() { | |||||
}.getType(); | |||||
Gson gson = new Gson(); | |||||
return gson.fromJson(json, collectionType); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import com.google.gson.Gson; | |||||
import com.google.gson.reflect.TypeToken; | |||||
import java.lang.reflect.Type; | |||||
import java.util.Collection; | |||||
import java.util.List; | |||||
/** | |||||
* Lite representation of JSON response of GET https://api.github.com/user/teams | |||||
*/ | |||||
public class GsonTeam { | |||||
private String slug; | |||||
private GsonOrganization organization; | |||||
public GsonTeam() { | |||||
// http://stackoverflow.com/a/18645370/229031 | |||||
this("", new GsonOrganization()); | |||||
} | |||||
public GsonTeam(String slug, GsonOrganization organization) { | |||||
this.slug = slug; | |||||
this.organization = organization; | |||||
} | |||||
public String getId() { | |||||
return slug; | |||||
} | |||||
public String getOrganizationId() { | |||||
return organization.getLogin(); | |||||
} | |||||
public static List<GsonTeam> parse(String json) { | |||||
Type collectionType = new TypeToken<Collection<GsonTeam>>() { | |||||
}.getType(); | |||||
Gson gson = new Gson(); | |||||
return gson.fromJson(json, collectionType); | |||||
} | |||||
public static class GsonOrganization { | |||||
private String login; | |||||
public GsonOrganization() { | |||||
// http://stackoverflow.com/a/18645370/229031 | |||||
this(""); | |||||
} | |||||
public GsonOrganization(String login) { | |||||
this.login = login; | |||||
} | |||||
public String getLogin() { | |||||
return login; | |||||
} | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import com.google.gson.Gson; | |||||
import javax.annotation.CheckForNull; | |||||
import javax.annotation.Nullable; | |||||
/** | |||||
* Lite representation of JSON response of GET https://api.github.com/user | |||||
*/ | |||||
public class GsonUser { | |||||
private String id; | |||||
private String login; | |||||
private String name; | |||||
private String email; | |||||
public GsonUser() { | |||||
// even if empty constructor is not required for Gson, it is strongly | |||||
// recommended: | |||||
// http://stackoverflow.com/a/18645370/229031 | |||||
} | |||||
public GsonUser(String id, String login, @Nullable String name, @Nullable String email) { | |||||
this.id = id; | |||||
this.login = login; | |||||
this.name = name; | |||||
this.email = email; | |||||
} | |||||
public String getId() { | |||||
return id; | |||||
} | |||||
public String getLogin() { | |||||
return login; | |||||
} | |||||
/** | |||||
* Name is optional at GitHub | |||||
*/ | |||||
@CheckForNull | |||||
public String getName() { | |||||
return name; | |||||
} | |||||
/** | |||||
* Name is optional at GitHub | |||||
*/ | |||||
@CheckForNull | |||||
public String getEmail() { | |||||
return email; | |||||
} | |||||
public static GsonUser parse(String json) { | |||||
Gson gson = new Gson(); | |||||
return gson.fromJson(json, GsonUser.class); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import com.github.scribejava.apis.GitHubApi; | |||||
public class ScribeGitHubApi extends GitHubApi { | |||||
private final GitHubSettings settings; | |||||
public ScribeGitHubApi(GitHubSettings settings) { | |||||
this.settings = settings; | |||||
} | |||||
@Override | |||||
public String getAccessTokenEndpoint() { | |||||
return settings.webURL() + "login/oauth/access_token"; | |||||
} | |||||
@Override | |||||
protected String getAuthorizationBaseUrl() { | |||||
return settings.webURL() + "login/oauth/authorize"; | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import java.util.List; | |||||
import javax.annotation.Nullable; | |||||
import org.sonar.api.server.authentication.UserIdentity; | |||||
/** | |||||
* Converts GitHub JSON response to {@link UserIdentity} | |||||
*/ | |||||
public interface UserIdentityFactory { | |||||
UserIdentity create(GsonUser user, @Nullable String email, @Nullable List<GsonTeam> teams); | |||||
} |
/* | |||||
* 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.github; | |||||
import java.util.List; | |||||
import java.util.stream.Collectors; | |||||
import javax.annotation.Nullable; | |||||
import org.sonar.api.server.authentication.UserIdentity; | |||||
import static org.sonar.auth.github.UserIdentityGenerator.generateLogin; | |||||
import static org.sonar.auth.github.UserIdentityGenerator.generateName; | |||||
public class UserIdentityFactoryImpl implements UserIdentityFactory { | |||||
private final GitHubSettings settings; | |||||
public UserIdentityFactoryImpl(GitHubSettings settings) { | |||||
this.settings = settings; | |||||
} | |||||
@Override | |||||
public UserIdentity create(GsonUser user, @Nullable String email, @Nullable List<GsonTeam> teams) { | |||||
UserIdentity.Builder builder = UserIdentity.builder() | |||||
.setProviderId(user.getId()) | |||||
.setProviderLogin(user.getLogin()) | |||||
.setLogin(generateLogin(user, settings.loginStrategy())) | |||||
.setName(generateName(user)) | |||||
.setEmail(email); | |||||
if (teams != null) { | |||||
builder.setGroups(teams.stream() | |||||
.map(team -> team.getOrganizationId() + "/" + team.getId()) | |||||
.collect(Collectors.toSet())); | |||||
} | |||||
return builder.build(); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import static java.lang.String.format; | |||||
import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID; | |||||
import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_UNIQUE; | |||||
class UserIdentityGenerator { | |||||
private UserIdentityGenerator() { | |||||
// Only static method | |||||
} | |||||
static String generateLogin(GsonUser gsonUser, String loginStrategy) { | |||||
switch (loginStrategy) { | |||||
case LOGIN_STRATEGY_PROVIDER_ID: | |||||
return gsonUser.getLogin(); | |||||
case LOGIN_STRATEGY_UNIQUE: | |||||
return generateUniqueLogin(gsonUser); | |||||
default: | |||||
throw new IllegalStateException(format("Login strategy not supported : %s", loginStrategy)); | |||||
} | |||||
} | |||||
static String generateName(GsonUser gson) { | |||||
String name = gson.getName(); | |||||
return name == null || name.isEmpty() ? gson.getLogin() : name; | |||||
} | |||||
private static String generateUniqueLogin(GsonUser gsonUser) { | |||||
return format("%s@%s", gsonUser.getLogin(), GitHubIdentityProvider.KEY); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import javax.annotation.ParametersAreNonnullByDefault; |
/* | |||||
* 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.github; | |||||
import org.junit.Rule; | |||||
import org.junit.Test; | |||||
import org.junit.rules.ExpectedException; | |||||
import org.sonar.api.config.internal.MapSettings; | |||||
import org.sonar.api.server.authentication.OAuth2IdentityProvider; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
import static org.mockito.Mockito.mock; | |||||
import static org.mockito.Mockito.verify; | |||||
import static org.mockito.Mockito.when; | |||||
import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_DEFAULT_VALUE; | |||||
public class GitHubIdentityProviderTest { | |||||
@Rule | |||||
public ExpectedException thrown = ExpectedException.none(); | |||||
private MapSettings settings = new MapSettings(); | |||||
private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig()); | |||||
private UserIdentityFactoryImpl userIdentityFactory = mock(UserIdentityFactoryImpl.class); | |||||
private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings); | |||||
private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings); | |||||
private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient); | |||||
@Test | |||||
public void check_fields() { | |||||
assertThat(underTest.getKey()).isEqualTo("github"); | |||||
assertThat(underTest.getName()).isEqualTo("GitHub"); | |||||
assertThat(underTest.getDisplay().getIconPath()).isEqualTo("/images/github.svg"); | |||||
assertThat(underTest.getDisplay().getBackgroundColor()).isEqualTo("#444444"); | |||||
} | |||||
@Test | |||||
public void is_enabled() { | |||||
settings.setProperty("sonar.auth.github.clientId.secured", "id"); | |||||
settings.setProperty("sonar.auth.github.clientSecret.secured", "secret"); | |||||
settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE); | |||||
settings.setProperty("sonar.auth.github.enabled", true); | |||||
assertThat(underTest.isEnabled()).isTrue(); | |||||
settings.setProperty("sonar.auth.github.enabled", false); | |||||
assertThat(underTest.isEnabled()).isFalse(); | |||||
} | |||||
@Test | |||||
public void should_allow_users_to_signup() { | |||||
assertThat(underTest.allowsUsersToSignUp()).as("default").isFalse(); | |||||
settings.setProperty("sonar.auth.github.allowUsersToSignUp", true); | |||||
assertThat(underTest.allowsUsersToSignUp()).isTrue(); | |||||
} | |||||
@Test | |||||
public void init() { | |||||
setSettings(true); | |||||
OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class); | |||||
when(context.generateCsrfState()).thenReturn("state"); | |||||
when(context.getCallbackUrl()).thenReturn("http://localhost/callback"); | |||||
settings.setProperty("sonar.auth.github.webUrl", "https://github.com/"); | |||||
underTest.init(context); | |||||
verify(context).redirectTo("https://github.com/login/oauth/authorize" + | |||||
"?response_type=code" + | |||||
"&client_id=id" + | |||||
"&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=user%3Aemail" + | |||||
"&state=state"); | |||||
} | |||||
@Test | |||||
public void init_when_group_sync() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.github.groupsSync", "true"); | |||||
settings.setProperty("sonar.auth.github.webUrl", "https://github.com/"); | |||||
OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class); | |||||
when(context.generateCsrfState()).thenReturn("state"); | |||||
when(context.getCallbackUrl()).thenReturn("http://localhost/callback"); | |||||
underTest.init(context); | |||||
verify(context).redirectTo("https://github.com/login/oauth/authorize" + | |||||
"?response_type=code" + | |||||
"&client_id=id" + | |||||
"&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=user%3Aemail%2Cread%3Aorg" + | |||||
"&state=state"); | |||||
} | |||||
@Test | |||||
public void init_when_organizations() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.github.organizations", "example"); | |||||
settings.setProperty("sonar.auth.github.webUrl", "https://github.com/"); | |||||
OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class); | |||||
when(context.generateCsrfState()).thenReturn("state"); | |||||
when(context.getCallbackUrl()).thenReturn("http://localhost/callback"); | |||||
underTest.init(context); | |||||
verify(context).redirectTo("https://github.com/login/oauth/authorize" + | |||||
"?response_type=code" + | |||||
"&client_id=id" + | |||||
"&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback" + | |||||
"&scope=user%3Aemail%2Cread%3Aorg" + | |||||
"&state=state"); | |||||
} | |||||
@Test | |||||
public void fail_to_init_when_disabled() { | |||||
setSettings(false); | |||||
OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class); | |||||
thrown.expect(IllegalStateException.class); | |||||
thrown.expectMessage("GitHub authentication is disabled"); | |||||
underTest.init(context); | |||||
} | |||||
@Test | |||||
public void scope_includes_org_when_necessary() { | |||||
setSettings(false); | |||||
settings.setProperty("sonar.auth.github.groupsSync", false); | |||||
settings.setProperty("sonar.auth.github.organizations", ""); | |||||
assertThat(underTest.getScope()).isEqualTo("user:email"); | |||||
settings.setProperty("sonar.auth.github.groupsSync", true); | |||||
settings.setProperty("sonar.auth.github.organizations", ""); | |||||
assertThat(underTest.getScope()).isEqualTo("user:email,read:org"); | |||||
settings.setProperty("sonar.auth.github.groupsSync", false); | |||||
settings.setProperty("sonar.auth.github.organizations", "example"); | |||||
assertThat(underTest.getScope()).isEqualTo("user:email,read:org"); | |||||
settings.setProperty("sonar.auth.github.groupsSync", true); | |||||
settings.setProperty("sonar.auth.github.organizations", "example"); | |||||
assertThat(underTest.getScope()).isEqualTo("user:email,read:org"); | |||||
} | |||||
@Test | |||||
public void organization_membership_required() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.github.organizations", "example"); | |||||
assertThat(underTest.isOrganizationMembershipRequired()).isTrue(); | |||||
settings.setProperty("sonar.auth.github.organizations", "example0, example1"); | |||||
assertThat(underTest.isOrganizationMembershipRequired()).isTrue(); | |||||
} | |||||
@Test | |||||
public void organization_membership_not_required() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.github.organizations", ""); | |||||
assertThat(underTest.isOrganizationMembershipRequired()).isFalse(); | |||||
} | |||||
private void setSettings(boolean enabled) { | |||||
if (enabled) { | |||||
settings.setProperty("sonar.auth.github.clientId.secured", "id"); | |||||
settings.setProperty("sonar.auth.github.clientSecret.secured", "secret"); | |||||
settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE); | |||||
settings.setProperty("sonar.auth.github.enabled", true); | |||||
} else { | |||||
settings.setProperty("sonar.auth.github.enabled", false); | |||||
} | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import org.junit.Test; | |||||
import org.sonar.core.platform.ComponentContainer; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER; | |||||
public class GitHubModuleTest { | |||||
@Test | |||||
public void verify_count_of_added_components() { | |||||
ComponentContainer container = new ComponentContainer(); | |||||
new GitHubModule().configure(container); | |||||
assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 14); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import org.junit.Test; | |||||
import org.sonar.api.config.PropertyDefinitions; | |||||
import org.sonar.api.config.internal.MapSettings; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_DEFAULT_VALUE; | |||||
import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID; | |||||
public class GitHubSettingsTest { | |||||
private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions())); | |||||
private GitHubSettings underTest = new GitHubSettings(settings.asConfig()); | |||||
@Test | |||||
public void is_enabled() { | |||||
settings.setProperty("sonar.auth.github.clientId.secured", "id"); | |||||
settings.setProperty("sonar.auth.github.clientSecret.secured", "secret"); | |||||
settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE); | |||||
settings.setProperty("sonar.auth.github.enabled", true); | |||||
assertThat(underTest.isEnabled()).isTrue(); | |||||
settings.setProperty("sonar.auth.github.enabled", false); | |||||
assertThat(underTest.isEnabled()).isFalse(); | |||||
} | |||||
@Test | |||||
public void is_enabled_always_return_false_when_client_id_is_null() { | |||||
settings.setProperty("sonar.auth.github.enabled", true); | |||||
settings.setProperty("sonar.auth.github.clientId.secured", (String) null); | |||||
settings.setProperty("sonar.auth.github.clientSecret.secured", "secret"); | |||||
settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE); | |||||
assertThat(underTest.isEnabled()).isFalse(); | |||||
} | |||||
@Test | |||||
public void is_enabled_always_return_false_when_client_secret_is_null() { | |||||
settings.setProperty("sonar.auth.github.enabled", true); | |||||
settings.setProperty("sonar.auth.github.clientId.secured", "id"); | |||||
settings.setProperty("sonar.auth.github.clientSecret.secured", (String) null); | |||||
settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE); | |||||
assertThat(underTest.isEnabled()).isFalse(); | |||||
} | |||||
@Test | |||||
public void return_client_id() { | |||||
settings.setProperty("sonar.auth.github.clientId.secured", "id"); | |||||
assertThat(underTest.clientId()).isEqualTo("id"); | |||||
} | |||||
@Test | |||||
public void return_client_secret() { | |||||
settings.setProperty("sonar.auth.github.clientSecret.secured", "secret"); | |||||
assertThat(underTest.clientSecret()).isEqualTo("secret"); | |||||
} | |||||
@Test | |||||
public void return_login_strategy() { | |||||
settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_PROVIDER_ID); | |||||
assertThat(underTest.loginStrategy()).isEqualTo(LOGIN_STRATEGY_PROVIDER_ID); | |||||
} | |||||
@Test | |||||
public void allow_users_to_sign_up() { | |||||
settings.setProperty("sonar.auth.github.allowUsersToSignUp", "true"); | |||||
assertThat(underTest.allowUsersToSignUp()).isTrue(); | |||||
settings.setProperty("sonar.auth.github.allowUsersToSignUp", "false"); | |||||
assertThat(underTest.allowUsersToSignUp()).isFalse(); | |||||
// default value | |||||
settings.setProperty("sonar.auth.github.allowUsersToSignUp", (String) null); | |||||
assertThat(underTest.allowUsersToSignUp()).isTrue(); | |||||
} | |||||
@Test | |||||
public void sync_groups() { | |||||
settings.setProperty("sonar.auth.github.groupsSync", "true"); | |||||
assertThat(underTest.syncGroups()).isTrue(); | |||||
settings.setProperty("sonar.auth.github.groupsSync", "false"); | |||||
assertThat(underTest.syncGroups()).isFalse(); | |||||
// default value | |||||
settings.setProperty("sonar.auth.github.groupsSync", (String) null); | |||||
assertThat(underTest.syncGroups()).isFalse(); | |||||
} | |||||
@Test | |||||
public void apiUrl_must_have_ending_slash() { | |||||
settings.setProperty("sonar.auth.github.apiUrl", "https://github.com"); | |||||
assertThat(underTest.apiURL()).isEqualTo("https://github.com/"); | |||||
settings.setProperty("sonar.auth.github.apiUrl", "https://github.com/"); | |||||
assertThat(underTest.apiURL()).isEqualTo("https://github.com/"); | |||||
} | |||||
@Test | |||||
public void webUrl_must_have_ending_slash() { | |||||
settings.setProperty("sonar.auth.github.webUrl", "https://github.com"); | |||||
assertThat(underTest.webURL()).isEqualTo("https://github.com/"); | |||||
settings.setProperty("sonar.auth.github.webUrl", "https://github.com/"); | |||||
assertThat(underTest.webURL()).isEqualTo("https://github.com/"); | |||||
} | |||||
@Test | |||||
public void return_organizations_single() { | |||||
String setting = "example"; | |||||
settings.setProperty("sonar.auth.github.organizations", setting); | |||||
String[] expected = new String[] {"example"}; | |||||
String[] actual = underTest.organizations(); | |||||
assertThat(actual).isEqualTo(expected); | |||||
} | |||||
@Test | |||||
public void return_organizations_multiple() { | |||||
String setting = "example0,example1"; | |||||
settings.setProperty("sonar.auth.github.organizations", setting); | |||||
String[] expected = new String[] {"example0", "example1"}; | |||||
String[] actual = underTest.organizations(); | |||||
assertThat(actual).isEqualTo(expected); | |||||
} | |||||
@Test | |||||
public void return_organizations_empty_list() { | |||||
String[] setting = null; | |||||
settings.setProperty("sonar.auth.github.organizations", setting); | |||||
String[] expected = new String[] {}; | |||||
String[] actual = underTest.organizations(); | |||||
assertThat(actual).isEqualTo(expected); | |||||
} | |||||
@Test | |||||
public void definitions() { | |||||
assertThat(GitHubSettings.definitions()).hasSize(9); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import java.util.List; | |||||
import org.junit.Test; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
public class GsonEmailTest { | |||||
@Test | |||||
public void parse() { | |||||
List<GsonEmail> underTest = GsonEmail.parse( | |||||
"[\n" + | |||||
" {\n" + | |||||
" \"email\": \"octocat@github.com\",\n" + | |||||
" \"verified\": true,\n" + | |||||
" \"primary\": true\n" + | |||||
" },\n" + | |||||
" {\n" + | |||||
" \"email\": \"support@github.com\",\n" + | |||||
" \"verified\": false,\n" + | |||||
" \"primary\": false\n" + | |||||
" }\n" + | |||||
"]"); | |||||
assertThat(underTest).hasSize(2); | |||||
assertThat(underTest.get(0).getEmail()).isEqualTo("octocat@github.com"); | |||||
assertThat(underTest.get(0).isVerified()).isTrue(); | |||||
assertThat(underTest.get(0).isPrimary()).isTrue(); | |||||
assertThat(underTest.get(1).getEmail()).isEqualTo("support@github.com"); | |||||
assertThat(underTest.get(1).isVerified()).isFalse(); | |||||
assertThat(underTest.get(1).isPrimary()).isFalse(); | |||||
} | |||||
@Test | |||||
public void should_have_no_arg_constructor() { | |||||
assertThat(new GsonEmail().getEmail()).isEqualTo(""); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import java.util.List; | |||||
import org.junit.Test; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
public class GsonTeamTest { | |||||
@Test | |||||
public void parse_one_team() { | |||||
List<GsonTeam> underTest = GsonTeam.parse( | |||||
"[\n" + | |||||
" {\n" + | |||||
" \"name\": \"Developers\",\n" + | |||||
" \"slug\": \"developers\",\n" + | |||||
" \"organization\": {\n" + | |||||
" \"login\": \"SonarSource\"\n" + | |||||
" }\n" + | |||||
" }\n" + | |||||
"]"); | |||||
assertThat(underTest).hasSize(1); | |||||
assertThat(underTest.get(0).getId()).isEqualTo("developers"); | |||||
assertThat(underTest.get(0).getOrganizationId()).isEqualTo("SonarSource"); | |||||
} | |||||
@Test | |||||
public void parse_two_teams() { | |||||
List<GsonTeam> underTest = GsonTeam.parse( | |||||
"[\n" + | |||||
" {\n" + | |||||
" \"name\": \"Developers\",\n" + | |||||
" \"slug\": \"developers\",\n" + | |||||
" \"organization\": {\n" + | |||||
" \"login\": \"SonarSource\"\n" + | |||||
" }\n" + | |||||
" },\n" + | |||||
" {\n" + | |||||
" \"login\": \"SonarSource Developers\",\n" + | |||||
" \"organization\": {\n" + | |||||
" \"login\": \"SonarQubeCommunity\"\n" + | |||||
" }\n" + | |||||
" }\n" + | |||||
"]"); | |||||
assertThat(underTest).hasSize(2); | |||||
} | |||||
@Test | |||||
public void should_have_no_arg_constructor() { | |||||
assertThat(new GsonTeam().getId()).isEqualTo(""); | |||||
assertThat(new GsonTeam.GsonOrganization().getLogin()).isEqualTo(""); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import org.junit.Test; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
public class GsonUserTest { | |||||
@Test | |||||
public void parse_json() { | |||||
GsonUser user = GsonUser.parse( | |||||
"{\n" + | |||||
" \"login\": \"octocat\",\n" + | |||||
" \"id\": 1,\n" + | |||||
" \"name\": \"monalisa octocat\",\n" + | |||||
" \"email\": \"octocat@github.com\"\n" + | |||||
"}"); | |||||
assertThat(user.getId()).isEqualTo("1"); | |||||
assertThat(user.getLogin()).isEqualTo("octocat"); | |||||
assertThat(user.getName()).isEqualTo("monalisa octocat"); | |||||
assertThat(user.getEmail()).isEqualTo("octocat@github.com"); | |||||
} | |||||
@Test | |||||
public void name_can_be_null() { | |||||
GsonUser underTest = GsonUser.parse("{login:octocat, email:octocat@github.com}"); | |||||
assertThat(underTest.getLogin()).isEqualTo("octocat"); | |||||
assertThat(underTest.getName()).isNull(); | |||||
} | |||||
@Test | |||||
public void email_can_be_null() { | |||||
GsonUser underTest = GsonUser.parse("{login:octocat}"); | |||||
assertThat(underTest.getLogin()).isEqualTo("octocat"); | |||||
assertThat(underTest.getEmail()).isNull(); | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import java.io.IOException; | |||||
import java.net.URLEncoder; | |||||
import java.nio.charset.StandardCharsets; | |||||
import java.util.TreeSet; | |||||
import java.util.concurrent.atomic.AtomicBoolean; | |||||
import javax.servlet.http.HttpServletRequest; | |||||
import javax.servlet.http.HttpServletResponse; | |||||
import okhttp3.mockwebserver.MockResponse; | |||||
import okhttp3.mockwebserver.MockWebServer; | |||||
import okhttp3.mockwebserver.RecordedRequest; | |||||
import org.junit.Before; | |||||
import org.junit.Rule; | |||||
import org.junit.Test; | |||||
import org.sonar.api.config.PropertyDefinitions; | |||||
import org.sonar.api.config.internal.MapSettings; | |||||
import org.sonar.api.server.authentication.OAuth2IdentityProvider; | |||||
import org.sonar.api.server.authentication.UnauthorizedException; | |||||
import org.sonar.api.server.authentication.UserIdentity; | |||||
import static java.lang.String.format; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
import static org.assertj.core.api.Assertions.fail; | |||||
import static org.mockito.Mockito.mock; | |||||
import static org.mockito.Mockito.when; | |||||
public class IntegrationTest { | |||||
private static final String CALLBACK_URL = "http://localhost/oauth/callback/github"; | |||||
@Rule | |||||
public MockWebServer github = new MockWebServer(); | |||||
// load settings with default values | |||||
private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions())); | |||||
private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig()); | |||||
private UserIdentityFactoryImpl userIdentityFactory = new UserIdentityFactoryImpl(gitHubSettings); | |||||
private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings); | |||||
private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings); | |||||
private String gitHubUrl; | |||||
private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient); | |||||
@Before | |||||
public void enable() { | |||||
gitHubUrl = format("http://%s:%d", github.getHostName(), github.getPort()); | |||||
settings.setProperty("sonar.auth.github.clientId.secured", "the_id"); | |||||
settings.setProperty("sonar.auth.github.clientSecret.secured", "the_secret"); | |||||
settings.setProperty("sonar.auth.github.enabled", true); | |||||
settings.setProperty("sonar.auth.github.apiUrl", gitHubUrl); | |||||
settings.setProperty("sonar.auth.github.webUrl", gitHubUrl); | |||||
} | |||||
/** | |||||
* First phase: SonarQube redirects browser to GitHub authentication form, requesting the | |||||
* minimal access rights ("scope") to get user profile (login, name, email and others). | |||||
*/ | |||||
@Test | |||||
public void redirect_browser_to_github_authentication_form() throws Exception { | |||||
DumbInitContext context = new DumbInitContext("the-csrf-state"); | |||||
underTest.init(context); | |||||
assertThat(context.redirectedTo).isEqualTo( | |||||
gitHubSettings.webURL() + | |||||
"login/oauth/authorize" + | |||||
"?response_type=code" + | |||||
"&client_id=the_id" + | |||||
"&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) + | |||||
"&scope=" + URLEncoder.encode("user:email", StandardCharsets.UTF_8.name()) + | |||||
"&state=the-csrf-state"); | |||||
} | |||||
/** | |||||
* Second phase: GitHub redirects browser to SonarQube at /oauth/callback/github?code={the verifier code}. | |||||
* This SonarQube web service sends two requests to GitHub: | |||||
* <ul> | |||||
* <li>get an access token</li> | |||||
* <li>get the profile of the authenticated user</li> | |||||
* </ul> | |||||
*/ | |||||
@Test | |||||
public void callback_on_successful_authentication() throws IOException, InterruptedException { | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.csrfStateVerified.get()).isTrue(); | |||||
assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD"); | |||||
assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github"); | |||||
assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat"); | |||||
assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com"); | |||||
assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue(); | |||||
// Verify the requests sent to GitHub | |||||
RecordedRequest accessTokenGitHubRequest = github.takeRequest(); | |||||
assertThat(accessTokenGitHubRequest.getMethod()).isEqualTo("POST"); | |||||
assertThat(accessTokenGitHubRequest.getPath()).isEqualTo("/login/oauth/access_token"); | |||||
assertThat(accessTokenGitHubRequest.getBody().readUtf8()).isEqualTo( | |||||
"code=the-verifier-code" + | |||||
"&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) + | |||||
"&grant_type=authorization_code"); | |||||
RecordedRequest profileGitHubRequest = github.takeRequest(); | |||||
assertThat(profileGitHubRequest.getMethod()).isEqualTo("GET"); | |||||
assertThat(profileGitHubRequest.getPath()).isEqualTo("/user"); | |||||
} | |||||
@Test | |||||
public void should_retrieve_private_primary_verified_email_address() { | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}")); | |||||
// response of api.github.com/user/emails | |||||
github.enqueue(new MockResponse().setBody( | |||||
"[\n" + | |||||
" {\n" + | |||||
" \"email\": \"support@github.com\",\n" + | |||||
" \"verified\": false,\n" + | |||||
" \"primary\": false\n" + | |||||
" },\n" + | |||||
" {\n" + | |||||
" \"email\": \"octocat@github.com\",\n" + | |||||
" \"verified\": true,\n" + | |||||
" \"primary\": true\n" + | |||||
" },\n" + | |||||
"]")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.csrfStateVerified.get()).isTrue(); | |||||
assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD"); | |||||
assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github"); | |||||
assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat"); | |||||
assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com"); | |||||
assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue(); | |||||
} | |||||
@Test | |||||
public void should_not_fail_if_no_email() { | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}")); | |||||
// response of api.github.com/user/emails | |||||
github.enqueue(new MockResponse().setBody("[]")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.csrfStateVerified.get()).isTrue(); | |||||
assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD"); | |||||
assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github"); | |||||
assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat"); | |||||
assertThat(callbackContext.userIdentity.getEmail()).isNull(); | |||||
assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue(); | |||||
} | |||||
@Test | |||||
public void redirect_browser_to_github_authentication_form_with_group_sync() throws Exception { | |||||
settings.setProperty("sonar.auth.github.groupsSync", true); | |||||
DumbInitContext context = new DumbInitContext("the-csrf-state"); | |||||
underTest.init(context); | |||||
assertThat(context.redirectedTo).isEqualTo( | |||||
gitHubSettings.webURL() + | |||||
"login/oauth/authorize" + | |||||
"?response_type=code" + | |||||
"&client_id=the_id" + | |||||
"&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) + | |||||
"&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) + | |||||
"&state=the-csrf-state"); | |||||
} | |||||
@Test | |||||
public void callback_on_successful_authentication_with_group_sync() { | |||||
settings.setProperty("sonar.auth.github.groupsSync", true); | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}")); | |||||
// response of api.github.com/user/teams | |||||
github.enqueue(new MockResponse().setBody("[\n" + | |||||
" {\n" + | |||||
" \"slug\": \"developers\",\n" + | |||||
" \"organization\": {\n" + | |||||
" \"login\": \"SonarSource\"\n" + | |||||
" }\n" + | |||||
" }\n" + | |||||
"]")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.userIdentity.getGroups()).containsOnly("SonarSource/developers"); | |||||
} | |||||
@Test | |||||
public void callback_on_successful_authentication_with_group_sync_on_many_pages() { | |||||
settings.setProperty("sonar.auth.github.groupsSync", true); | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}")); | |||||
// responses of api.github.com/user/teams | |||||
github.enqueue(new MockResponse() | |||||
.setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"next\", <" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"last\"") | |||||
.setBody("[\n" + | |||||
" {\n" + | |||||
" \"slug\": \"developers\",\n" + | |||||
" \"organization\": {\n" + | |||||
" \"login\": \"SonarSource\"\n" + | |||||
" }\n" + | |||||
" }\n" + | |||||
"]")); | |||||
github.enqueue(new MockResponse() | |||||
.setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"prev\", <" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"first\"") | |||||
.setBody("[\n" + | |||||
" {\n" + | |||||
" \"slug\": \"sonarsource-developers\",\n" + | |||||
" \"organization\": {\n" + | |||||
" \"login\": \"SonarQubeCommunity\"\n" + | |||||
" }\n" + | |||||
" }\n" + | |||||
"]")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
underTest.callback(callbackContext); | |||||
assertThat(new TreeSet<>(callbackContext.userIdentity.getGroups())).containsOnly("SonarQubeCommunity/sonarsource-developers", "SonarSource/developers"); | |||||
} | |||||
@Test | |||||
public void redirect_browser_to_github_authentication_form_with_organizations() throws Exception { | |||||
settings.setProperty("sonar.auth.github.organizations", "example0, example1"); | |||||
DumbInitContext context = new DumbInitContext("the-csrf-state"); | |||||
underTest.init(context); | |||||
assertThat(context.redirectedTo).isEqualTo( | |||||
gitHubSettings.webURL() + | |||||
"login/oauth/authorize" + | |||||
"?response_type=code" + | |||||
"&client_id=the_id" + | |||||
"&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) + | |||||
"&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) + | |||||
"&state=the-csrf-state"); | |||||
} | |||||
@Test | |||||
public void callback_on_successful_authentication_with_organizations_with_membership() { | |||||
settings.setProperty("sonar.auth.github.organizations", "example0, example1"); | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}")); | |||||
// response of api.github.com/orgs/example0/members/user | |||||
github.enqueue(new MockResponse().setResponseCode(204)); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.csrfStateVerified.get()).isTrue(); | |||||
assertThat(callbackContext.userIdentity).isNotNull(); | |||||
assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue(); | |||||
} | |||||
@Test | |||||
public void callback_on_successful_authentication_with_organizations_without_membership() { | |||||
settings.setProperty("sonar.auth.github.organizations", "first_org,second_org"); | |||||
settings.setProperty("sonar.auth.github.loginStrategy", GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID); | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}")); | |||||
// response of api.github.com/orgs/first_org/members/user | |||||
github.enqueue(new MockResponse().setResponseCode(404).setBody("{}")); | |||||
// response of api.github.com/orgs/second_org/members/user | |||||
github.enqueue(new MockResponse().setResponseCode(404).setBody("{}")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
try { | |||||
underTest.callback(callbackContext); | |||||
fail("exception expected"); | |||||
} catch (UnauthorizedException e) { | |||||
assertThat(e.getMessage()).isEqualTo("'octocat' must be a member of at least one organization: 'first_org', 'second_org'"); | |||||
} | |||||
} | |||||
@Test | |||||
public void callback_on_successful_authentication_with_organizations_without_membership_with_unique_login_strategy() { | |||||
settings.setProperty("sonar.auth.github.organizations", "example"); | |||||
settings.setProperty("sonar.auth.github.loginStrategy", GitHubSettings.LOGIN_STRATEGY_UNIQUE); | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}")); | |||||
// response of api.github.com/orgs/example0/members/user | |||||
github.enqueue(new MockResponse().setResponseCode(404).setBody("{}")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
try { | |||||
underTest.callback(callbackContext); | |||||
fail("exception expected"); | |||||
} catch (UnauthorizedException e) { | |||||
assertThat(e.getMessage()).isEqualTo("'octocat' must be a member of at least one organization: 'example'"); | |||||
} | |||||
} | |||||
@Test | |||||
public void callback_throws_ISE_if_error_when_requesting_user_profile() { | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// api.github.com/user crashes | |||||
github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}")); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(newRequest("the-verifier-code")); | |||||
try { | |||||
underTest.callback(callbackContext); | |||||
fail("exception expected"); | |||||
} catch (IllegalStateException e) { | |||||
assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "user'. HTTP code: 500, response: {error}"); | |||||
} | |||||
assertThat(callbackContext.csrfStateVerified.get()).isTrue(); | |||||
assertThat(callbackContext.userIdentity).isNull(); | |||||
assertThat(callbackContext.redirectedToRequestedPage.get()).isFalse(); | |||||
} | |||||
@Test | |||||
public void callback_throws_ISE_if_error_when_checking_membership() { | |||||
settings.setProperty("sonar.auth.github.organizations", "example"); | |||||
github.enqueue(newSuccessfulAccessTokenResponse()); | |||||
// response of api.github.com/user | |||||
github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}")); | |||||
// crash of api.github.com/orgs/example/members/user | |||||
github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}")); | |||||
HttpServletRequest request = newRequest("the-verifier-code"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext(request); | |||||
try { | |||||
underTest.callback(callbackContext); | |||||
fail("exception expected"); | |||||
} catch (IllegalStateException e) { | |||||
assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "orgs/example/members/octocat'. HTTP code: 500, response: {error}"); | |||||
} | |||||
} | |||||
/** | |||||
* Response sent by GitHub to SonarQube when generating an access token | |||||
*/ | |||||
private static MockResponse newSuccessfulAccessTokenResponse() { | |||||
// github does not return the standard JSON format but plain-text | |||||
// see https://developer.github.com/v3/oauth/ | |||||
return new MockResponse().setBody("access_token=e72e16c7e42f292c6912e7710c838347ae178b4a&scope=user%2Cgist&token_type=bearer"); | |||||
} | |||||
private static HttpServletRequest newRequest(String verifierCode) { | |||||
HttpServletRequest request = mock(HttpServletRequest.class); | |||||
when(request.getParameter("code")).thenReturn(verifierCode); | |||||
return request; | |||||
} | |||||
private static class DumbCallbackContext implements OAuth2IdentityProvider.CallbackContext { | |||||
final HttpServletRequest request; | |||||
final AtomicBoolean csrfStateVerified = new AtomicBoolean(false); | |||||
final AtomicBoolean redirectedToRequestedPage = new AtomicBoolean(false); | |||||
UserIdentity userIdentity = null; | |||||
public DumbCallbackContext(HttpServletRequest request) { | |||||
this.request = request; | |||||
} | |||||
@Override | |||||
public void verifyCsrfState() { | |||||
this.csrfStateVerified.set(true); | |||||
} | |||||
@Override | |||||
public void verifyCsrfState(String parameterName) { | |||||
throw new UnsupportedOperationException("not used"); | |||||
} | |||||
@Override | |||||
public void redirectToRequestedPage() { | |||||
redirectedToRequestedPage.set(true); | |||||
} | |||||
@Override | |||||
public void authenticate(UserIdentity userIdentity) { | |||||
this.userIdentity = userIdentity; | |||||
} | |||||
@Override | |||||
public String getCallbackUrl() { | |||||
return CALLBACK_URL; | |||||
} | |||||
@Override | |||||
public HttpServletRequest getRequest() { | |||||
return request; | |||||
} | |||||
@Override | |||||
public HttpServletResponse getResponse() { | |||||
throw new UnsupportedOperationException("not used"); | |||||
} | |||||
} | |||||
private static class DumbInitContext implements OAuth2IdentityProvider.InitContext { | |||||
String redirectedTo = null; | |||||
private final String generatedCsrfState; | |||||
public DumbInitContext(String generatedCsrfState) { | |||||
this.generatedCsrfState = generatedCsrfState; | |||||
} | |||||
@Override | |||||
public String generateCsrfState() { | |||||
return generatedCsrfState; | |||||
} | |||||
@Override | |||||
public void redirectTo(String url) { | |||||
this.redirectedTo = url; | |||||
} | |||||
@Override | |||||
public String getCallbackUrl() { | |||||
return CALLBACK_URL; | |||||
} | |||||
@Override | |||||
public HttpServletRequest getRequest() { | |||||
return null; | |||||
} | |||||
@Override | |||||
public HttpServletResponse getResponse() { | |||||
return null; | |||||
} | |||||
} | |||||
} |
/* | |||||
* 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.github; | |||||
import java.util.Arrays; | |||||
import java.util.List; | |||||
import org.junit.Rule; | |||||
import org.junit.Test; | |||||
import org.junit.rules.ExpectedException; | |||||
import org.sonar.api.config.PropertyDefinitions; | |||||
import org.sonar.api.config.internal.MapSettings; | |||||
import org.sonar.api.server.authentication.UserIdentity; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
public class UserIdentityFactoryImplTest { | |||||
@Rule | |||||
public ExpectedException expectedException = ExpectedException.none(); | |||||
private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions())); | |||||
private UserIdentityFactoryImpl underTest = new UserIdentityFactoryImpl(new GitHubSettings(settings.asConfig())); | |||||
/** | |||||
* Keep the same login as at GitHub | |||||
*/ | |||||
@Test | |||||
public void create_for_provider_strategy() { | |||||
GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com"); | |||||
settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID); | |||||
UserIdentity identity = underTest.create(gson, gson.getEmail(), null); | |||||
assertThat(identity.getProviderId()).isEqualTo("ABCD"); | |||||
assertThat(identity.getLogin()).isEqualTo("octocat"); | |||||
assertThat(identity.getName()).isEqualTo("monalisa octocat"); | |||||
assertThat(identity.getEmail()).isEqualTo("octocat@github.com"); | |||||
} | |||||
@Test | |||||
public void no_email() { | |||||
GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", null); | |||||
settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID); | |||||
UserIdentity identity = underTest.create(gson, null, null); | |||||
assertThat(identity.getLogin()).isEqualTo("octocat"); | |||||
assertThat(identity.getName()).isEqualTo("monalisa octocat"); | |||||
assertThat(identity.getEmail()).isNull(); | |||||
} | |||||
@Test | |||||
public void create_for_provider_strategy_with_teams() { | |||||
GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com"); | |||||
List<GsonTeam> teams = Arrays.asList( | |||||
new GsonTeam("developers", new GsonTeam.GsonOrganization("SonarSource"))); | |||||
settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID); | |||||
UserIdentity identity = underTest.create(gson, null, teams); | |||||
assertThat(identity.getGroups()).containsOnly("SonarSource/developers"); | |||||
} | |||||
@Test | |||||
public void create_for_unique_login_strategy() { | |||||
GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com"); | |||||
settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_UNIQUE); | |||||
UserIdentity identity = underTest.create(gson, null, null); | |||||
assertThat(identity.getLogin()).isEqualTo("octocat@github"); | |||||
assertThat(identity.getName()).isEqualTo("monalisa octocat"); | |||||
assertThat(identity.getEmail()).isNull(); | |||||
} | |||||
@Test | |||||
public void empty_name_is_replaced_by_provider_login() { | |||||
GsonUser gson = new GsonUser("ABCD", "octocat", "", "octocat@github.com"); | |||||
UserIdentity identity = underTest.create(gson, null, null); | |||||
assertThat(identity.getName()).isEqualTo("octocat"); | |||||
} | |||||
@Test | |||||
public void null_name_is_replaced_by_provider_login() { | |||||
GsonUser gson = new GsonUser("ABCD", "octocat", null, "octocat@github.com"); | |||||
UserIdentity identity = underTest.create(gson, null, null); | |||||
assertThat(identity.getName()).isEqualTo("octocat"); | |||||
} | |||||
@Test | |||||
public void throw_ISE_if_strategy_is_not_supported() { | |||||
settings.setProperty(GitHubSettings.LOGIN_STRATEGY, "xxx"); | |||||
expectedException.expect(IllegalStateException.class); | |||||
expectedException.expectMessage("Login strategy not supported : xxx"); | |||||
underTest.create(new GsonUser("ABCD", "octocat", "octocat", "octocat@github.com"), null, null); | |||||
} | |||||
} |
compile 'com.github.scribejava:scribejava-apis' | compile 'com.github.scribejava:scribejava-apis' | ||||
compile 'com.github.scribejava:scribejava-core' | compile 'com.github.scribejava:scribejava-core' | ||||
compile 'com.google.code.gson:gson' | compile 'com.google.code.gson:gson' | ||||
compile project(':server:sonar-auth-common') | |||||
compileOnly 'com.google.code.findbugs:jsr305' | compileOnly 'com.google.code.findbugs:jsr305' | ||||
compileOnly 'com.squareup.okhttp3:okhttp' | compileOnly 'com.squareup.okhttp3:okhttp' |
@Override | @Override | ||||
public Display getDisplay() { | public Display getDisplay() { | ||||
return Display.builder() | return Display.builder() | ||||
.setIconPath("/images/gitlab-icon-rgb.svg").setBackgroundColor("#6a4fbb").build(); | |||||
.setIconPath("/images/gitlab-icon-rgb.svg") | |||||
.setBackgroundColor("#6a4fbb") | |||||
.build(); | |||||
} | } | ||||
@Override | @Override |
package org.sonar.auth.gitlab; | package org.sonar.auth.gitlab; | ||||
import com.github.scribejava.core.model.OAuth2AccessToken; | 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.Response; | ||||
import com.github.scribejava.core.model.Verb; | |||||
import com.github.scribejava.core.oauth.OAuth20Service; | import com.github.scribejava.core.oauth.OAuth20Service; | ||||
import java.io.IOException; | import java.io.IOException; | ||||
import java.util.ArrayList; | |||||
import java.util.List; | 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; | |||||
import org.sonar.auth.OAuthRestClient; | |||||
public class GitLabRestClient { | public class GitLabRestClient { | ||||
private static final int DEFAULT_PAGE_SIZE = 100; | |||||
private static final Pattern NEXT_LINK_PATTERN = Pattern.compile(".*<(.*)>; rel=\"next\""); | |||||
private static final String API_SUFFIX = "/api/v4"; | private static final String API_SUFFIX = "/api/v4"; | ||||
private final GitLabSettings settings; | private final GitLabSettings settings; | ||||
} | } | ||||
GsonUser getUser(OAuth20Service scribe, OAuth2AccessToken accessToken) { | GsonUser getUser(OAuth20Service scribe, OAuth2AccessToken accessToken) { | ||||
try (Response response = executeRequest(settings.url() + API_SUFFIX + "/user", scribe, accessToken)) { | |||||
try (Response response = OAuthRestClient.executeRequest(settings.url() + API_SUFFIX + "/user", scribe, accessToken)) { | |||||
String responseBody = response.getBody(); | String responseBody = response.getBody(); | ||||
return GsonUser.parse(responseBody); | return GsonUser.parse(responseBody); | ||||
} catch (IOException e) { | } catch (IOException e) { | ||||
} | } | ||||
List<GsonGroup> getGroups(OAuth20Service scribe, OAuth2AccessToken accessToken) { | List<GsonGroup> getGroups(OAuth20Service scribe, OAuth2AccessToken accessToken) { | ||||
return executePaginatedQuery(settings.url() + API_SUFFIX + "/groups", scribe, accessToken, GsonGroup::parse); | |||||
} | |||||
private 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); | |||||
} | |||||
} | |||||
private static <E> List<E> executePaginatedQuery(String query, OAuth20Service scribe, OAuth2AccessToken accessToken, Function<String, List<E>> function) { | |||||
List<E> result = new ArrayList<>(); | |||||
readNextPage(result, scribe, accessToken, query + "?per_page=" + DEFAULT_PAGE_SIZE, function); | |||||
return result; | |||||
} | |||||
private static <E> void readNextPage(List<E> result, OAuth20Service scribe, OAuth2AccessToken accessToken, String nextEndPoint, Function<String, List<E>> function) { | |||||
try (Response nextResponse = executeRequest(nextEndPoint, scribe, accessToken)) { | |||||
String content = nextResponse.getBody(); | |||||
if (content == null) { | |||||
return; | |||||
} | |||||
result.addAll(function.apply(content)); | |||||
readNextEndPoint(nextResponse).ifPresent(newNextEndPoint -> readNextPage(result, scribe, accessToken, newNextEndPoint, function)); | |||||
} catch (IOException e) { | |||||
throw new IllegalStateException(format("Failed to get %s", nextEndPoint), e); | |||||
} | |||||
return OAuthRestClient.executePaginatedRequest(settings.url() + API_SUFFIX + "/groups", scribe, accessToken, GsonGroup::parse); | |||||
} | } | ||||
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())); | |||||
} | |||||
} | } |
1. You'll need to first create a GitHub OAuth application. Click [here](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) for general instructions: | 1. You'll need to first create a GitHub OAuth application. Click [here](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) for general instructions: | ||||
1. "Homepage URL" is the public URL to your SonarQube server, for example "https://sonarqube.mycompany.com". For security reasons HTTP is not supported. HTTPS must be used. The public URL is configured in SonarQube at **[Administration -> General -> Server base URL](/#sonarqube-admin#/admin/settings)** | 1. "Homepage URL" is the public URL to your SonarQube server, for example "https://sonarqube.mycompany.com". For security reasons HTTP is not supported. HTTPS must be used. The public URL is configured in SonarQube at **[Administration -> General -> Server base URL](/#sonarqube-admin#/admin/settings)** | ||||
1. "Authorization callback URL" is <Homepage URL>/oauth2/callback, for example "https://sonarqube.mycompany.com/oauth2/callback" | 1. "Authorization callback URL" is <Homepage URL>/oauth2/callback, for example "https://sonarqube.mycompany.com/oauth2/callback" | ||||
1. In SonarQube, navigate to **[Administration > Configuration > General Settings > GitHub](/#sonarqube-admin#/admin/settings?category=github)**: | |||||
1. In SonarQube, navigate to **[Administration > Configuration > General Settings > Security > GitHub](/#sonarqube-admin#/admin/settings?category=security)**: | |||||
1. Set **Enabled** to `true` | 1. Set **Enabled** to `true` | ||||
1. Set the **Client ID** to the value provided by the GitHub developer application | 1. Set the **Client ID** to the value provided by the GitHub developer application | ||||
1. Set the **Client Secret** to the value provided by the GitHub developer application | 1. Set the **Client Secret** to the value provided by the GitHub developer application |
*/ | */ | ||||
public abstract class ServerExtensionInstaller { | public abstract class ServerExtensionInstaller { | ||||
private static final Set<String> NO_MORE_COMPATIBLE_PLUGINS = ImmutableSet.of("authgitlab"); | |||||
private static final Set<String> NO_MORE_COMPATIBLE_PLUGINS = ImmutableSet.of("authgithub", "authgitlab"); | |||||
private final SonarRuntime sonarRuntime; | private final SonarRuntime sonarRuntime; | ||||
private final PluginRepository pluginRepository; | private final PluginRepository pluginRepository; |
assertThat(componentContainer.getPicoContainer().getComponents()).contains(fooPlugin); | assertThat(componentContainer.getPicoContainer().getComponents()).contains(fooPlugin); | ||||
} | } | ||||
@Test | |||||
public void fail_when_detecting_github_auth_plugin() { | |||||
PluginInfo foo = newPlugin("authgithub", "GitHub Auth"); | |||||
pluginRepository.add(foo, mock(Plugin.class)); | |||||
ComponentContainer componentContainer = new ComponentContainer(); | |||||
expectedException.expect(MessageException.class); | |||||
expectedException.expectMessage("Plugins 'GitHub Auth' are no more compatible with SonarQube"); | |||||
underTest.installExtensions(componentContainer); | |||||
} | |||||
@Test | @Test | ||||
public void fail_when_detecting_gitlab_auth_plugin() { | public void fail_when_detecting_gitlab_auth_plugin() { | ||||
PluginInfo foo = newPlugin("authgitlab", "GitLab Auth"); | PluginInfo foo = newPlugin("authgitlab", "GitLab Auth"); |
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0" y="0" viewBox="0 0 438.5 438.5" xml:space="preserve"> | |||||
<path fill="#fff" | |||||
d="M409.1 114.6c-19.6-33.6-46.2-60.2-79.8-79.8C295.7 15.2 259.1 5.4 219.3 5.4c-39.8 0-76.5 9.8-110.1 29.4 -33.6 19.6-60.2 46.2-79.8 79.8C9.8 148.2 0 184.9 0 224.6c0 47.8 13.9 90.7 41.8 128.9 27.9 38.2 63.9 64.6 108.1 79.2 5.1 1 8.9 0.3 11.4-2 2.5-2.3 3.7-5.1 3.7-8.6 0-0.6 0-5.7-0.1-15.4 -0.1-9.7-0.1-18.2-0.1-25.4l-6.6 1.1c-4.2 0.8-9.5 1.1-15.8 1 -6.4-0.1-13-0.8-19.8-2 -6.9-1.2-13.2-4.1-19.1-8.6 -5.9-4.5-10.1-10.3-12.6-17.6l-2.9-6.6c-1.9-4.4-4.9-9.2-9-14.6 -4.1-5.3-8.2-8.9-12.4-10.8l-2-1.4c-1.3-1-2.6-2.1-3.7-3.4 -1.1-1.3-2-2.7-2.6-4 -0.6-1.3-0.1-2.4 1.4-3.3 1.5-0.9 4.3-1.3 8.3-1.3l5.7 0.9c3.8 0.8 8.5 3 14.1 6.9 5.6 3.8 10.2 8.8 13.8 14.8 4.4 7.8 9.7 13.8 15.8 17.8 6.2 4.1 12.4 6.1 18.7 6.1 6.3 0 11.7-0.5 16.3-1.4 4.6-1 8.8-2.4 12.8-4.3 1.7-12.8 6.4-22.6 14-29.4 -10.8-1.1-20.6-2.9-29.3-5.1 -8.7-2.3-17.6-6-26.8-11.1 -9.2-5.1-16.9-11.5-23-19.1 -6.1-7.6-11.1-17.6-15-30 -3.9-12.4-5.9-26.6-5.9-42.8 0-23 7.5-42.6 22.6-58.8 -7-17.3-6.4-36.7 2-58.2 5.5-1.7 13.7-0.4 24.6 3.9 10.9 4.3 18.8 8 23.8 11 5 3 9.1 5.6 12.1 7.7 17.7-4.9 36-7.4 54.8-7.4s37.1 2.5 54.8 7.4l10.8-6.8c7.4-4.6 16.2-8.8 26.3-12.6 10.1-3.8 17.8-4.9 23.1-3.1 8.6 21.5 9.3 40.9 2.3 58.2 15 16.2 22.6 35.8 22.6 58.8 0 16.2-2 30.5-5.9 43 -3.9 12.5-8.9 22.5-15.1 30 -6.2 7.5-13.9 13.9-23.1 19 -9.2 5.1-18.2 8.9-26.8 11.1 -8.7 2.3-18.4 4-29.3 5.1 9.9 8.6 14.8 22.1 14.8 40.5v60.2c0 3.4 1.2 6.3 3.6 8.6 2.4 2.3 6.1 3 11.3 2 44.2-14.7 80.2-41.1 108.1-79.2 27.9-38.2 41.8-81.1 41.8-128.9C438.5 184.9 428.7 148.2 409.1 114.6z"/> | |||||
</svg> |
compile 'com.google.guava:guava' | compile 'com.google.guava:guava' | ||||
compile 'org.apache.tomcat.embed:tomcat-embed-core' | compile 'org.apache.tomcat.embed:tomcat-embed-core' | ||||
compile project(':sonar-core') | compile project(':sonar-core') | ||||
compile project(':server:sonar-auth-github') | |||||
compile project(':server:sonar-auth-gitlab') | compile project(':server:sonar-auth-gitlab') | ||||
compile project(':server:sonar-ce-task-projectanalysis') | compile project(':server:sonar-ce-task-projectanalysis') | ||||
compile project(':server:sonar-process') | compile project(':server:sonar-process') |
import org.sonar.api.rules.AnnotationRuleParser; | import org.sonar.api.rules.AnnotationRuleParser; | ||||
import org.sonar.api.rules.XMLRuleParser; | import org.sonar.api.rules.XMLRuleParser; | ||||
import org.sonar.api.server.rule.RulesDefinitionXmlLoader; | import org.sonar.api.server.rule.RulesDefinitionXmlLoader; | ||||
import org.sonar.auth.github.GitHubModule; | |||||
import org.sonar.auth.gitlab.GitLabModule; | import org.sonar.auth.gitlab.GitLabModule; | ||||
import org.sonar.ce.task.projectanalysis.notification.ReportAnalysisFailureNotificationModule; | import org.sonar.ce.task.projectanalysis.notification.ReportAnalysisFailureNotificationModule; | ||||
import org.sonar.ce.task.projectanalysis.taskprocessor.ReportTaskProcessor; | import org.sonar.ce.task.projectanalysis.taskprocessor.ReportTaskProcessor; | ||||
// authentication | // authentication | ||||
AuthenticationModule.class, | AuthenticationModule.class, | ||||
AuthenticationWsModule.class, | AuthenticationWsModule.class, | ||||
GitHubModule.class, | |||||
GitLabModule.class, | GitLabModule.class, | ||||
// users | // users |
include 'plugins:sonar-xoo-plugin' | include 'plugins:sonar-xoo-plugin' | ||||
include 'server:sonar-auth-common' | |||||
include 'server:sonar-auth-github' | |||||
include 'server:sonar-auth-gitlab' | include 'server:sonar-auth-gitlab' | ||||
include 'server:sonar-ce' | include 'server:sonar-ce' | ||||
include 'server:sonar-ce-common' | include 'server:sonar-ce-common' | ||||
enabled = !isCiServer | enabled = !isCiServer | ||||
} | } | ||||
} | } | ||||
jdbc_mssql 'com.microsoft.sqlserver:mssql-jdbc' | jdbc_mssql 'com.microsoft.sqlserver:mssql-jdbc' | ||||
jdbc_postgresql 'org.postgresql:postgresql' | jdbc_postgresql 'org.postgresql:postgresql' | ||||
bundledPlugin 'org.sonarsource.auth.github:sonar-auth-github-plugin:1.5.0.870@jar' | |||||
bundledPlugin 'org.sonarsource.auth.saml:sonar-auth-saml-plugin:1.1.0.181@jar' | bundledPlugin 'org.sonarsource.auth.saml:sonar-auth-saml-plugin:1.1.0.181@jar' | ||||
bundledPlugin 'org.sonarsource.css:sonar-css-plugin@jar' | bundledPlugin 'org.sonarsource.css:sonar-css-plugin@jar' | ||||
bundledPlugin "org.sonarsource.dotnet:sonar-csharp-plugin@jar" | bundledPlugin "org.sonarsource.dotnet:sonar-csharp-plugin@jar" |
property.category.organizations=Organizations | property.category.organizations=Organizations | ||||
property.category.security=Security | property.category.security=Security | ||||
property.category.security.encryption=Encryption | property.category.security.encryption=Encryption | ||||
property.category.security.github=GitHub | |||||
property.category.security.github.description=In order to enable GitHub authentication:<ul><li>SonarQube must be publicly accessible through HTTPS only</li><li>The property 'sonar.core.serverBaseURL' must be set to this public HTTPS URL</li><li>In your GitHub profile, you need to create a Developer Application for which the 'Authorization callback URL' must be set to <code>'<value_of_sonar.core.serverBaseURL_property>/oauth2/callback'</code>.</li></ul> | |||||
property.category.security.gitlab=Gitlab | property.category.security.gitlab=Gitlab | ||||
property.category.java=Java | property.category.java=Java | ||||
property.category.differentialViews=New Code | property.category.differentialViews=New Code |