You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

IntegrationTest.java 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2021 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. package org.sonar.auth.github;
  21. import java.io.IOException;
  22. import java.net.URLEncoder;
  23. import java.nio.charset.StandardCharsets;
  24. import java.util.TreeSet;
  25. import java.util.concurrent.atomic.AtomicBoolean;
  26. import javax.servlet.http.HttpServletRequest;
  27. import javax.servlet.http.HttpServletResponse;
  28. import okhttp3.mockwebserver.MockResponse;
  29. import okhttp3.mockwebserver.MockWebServer;
  30. import okhttp3.mockwebserver.RecordedRequest;
  31. import org.junit.Before;
  32. import org.junit.Rule;
  33. import org.junit.Test;
  34. import org.sonar.api.config.PropertyDefinitions;
  35. import org.sonar.api.config.internal.MapSettings;
  36. import org.sonar.api.server.authentication.OAuth2IdentityProvider;
  37. import org.sonar.api.server.authentication.UnauthorizedException;
  38. import org.sonar.api.server.authentication.UserIdentity;
  39. import org.sonar.api.utils.System2;
  40. import static java.lang.String.format;
  41. import static org.assertj.core.api.Assertions.assertThat;
  42. import static org.assertj.core.api.Assertions.fail;
  43. import static org.mockito.Mockito.mock;
  44. import static org.mockito.Mockito.when;
  45. public class IntegrationTest {
  46. private static final String CALLBACK_URL = "http://localhost/oauth/callback/github";
  47. @Rule
  48. public MockWebServer github = new MockWebServer();
  49. // load settings with default values
  50. private MapSettings settings = new MapSettings(new PropertyDefinitions(System2.INSTANCE, GitHubSettings.definitions()));
  51. private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig());
  52. private UserIdentityFactoryImpl userIdentityFactory = new UserIdentityFactoryImpl();
  53. private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings);
  54. private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings);
  55. private String gitHubUrl;
  56. private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient);
  57. @Before
  58. public void enable() {
  59. gitHubUrl = format("http://%s:%d", github.getHostName(), github.getPort());
  60. settings.setProperty("sonar.auth.github.clientId.secured", "the_id");
  61. settings.setProperty("sonar.auth.github.clientSecret.secured", "the_secret");
  62. settings.setProperty("sonar.auth.github.enabled", true);
  63. settings.setProperty("sonar.auth.github.apiUrl", gitHubUrl);
  64. settings.setProperty("sonar.auth.github.webUrl", gitHubUrl);
  65. }
  66. /**
  67. * First phase: SonarQube redirects browser to GitHub authentication form, requesting the
  68. * minimal access rights ("scope") to get user profile (login, name, email and others).
  69. */
  70. @Test
  71. public void redirect_browser_to_github_authentication_form() throws Exception {
  72. DumbInitContext context = new DumbInitContext("the-csrf-state");
  73. underTest.init(context);
  74. assertThat(context.redirectedTo).isEqualTo(
  75. gitHubSettings.webURL() +
  76. "login/oauth/authorize" +
  77. "?response_type=code" +
  78. "&client_id=the_id" +
  79. "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
  80. "&scope=" + URLEncoder.encode("user:email", StandardCharsets.UTF_8.name()) +
  81. "&state=the-csrf-state");
  82. }
  83. /**
  84. * Second phase: GitHub redirects browser to SonarQube at /oauth/callback/github?code={the verifier code}.
  85. * This SonarQube web service sends two requests to GitHub:
  86. * <ul>
  87. * <li>get an access token</li>
  88. * <li>get the profile of the authenticated user</li>
  89. * </ul>
  90. */
  91. @Test
  92. public void callback_on_successful_authentication() throws IOException, InterruptedException {
  93. github.enqueue(newSuccessfulAccessTokenResponse());
  94. // response of api.github.com/user
  95. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
  96. HttpServletRequest request = newRequest("the-verifier-code");
  97. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  98. underTest.callback(callbackContext);
  99. assertThat(callbackContext.csrfStateVerified.get()).isTrue();
  100. assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
  101. assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("octocat");
  102. assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
  103. assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com");
  104. assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
  105. // Verify the requests sent to GitHub
  106. RecordedRequest accessTokenGitHubRequest = github.takeRequest();
  107. assertThat(accessTokenGitHubRequest.getMethod()).isEqualTo("POST");
  108. assertThat(accessTokenGitHubRequest.getPath()).isEqualTo("/login/oauth/access_token");
  109. assertThat(accessTokenGitHubRequest.getBody().readUtf8()).isEqualTo(
  110. "code=the-verifier-code" +
  111. "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
  112. "&grant_type=authorization_code");
  113. RecordedRequest profileGitHubRequest = github.takeRequest();
  114. assertThat(profileGitHubRequest.getMethod()).isEqualTo("GET");
  115. assertThat(profileGitHubRequest.getPath()).isEqualTo("/user");
  116. }
  117. @Test
  118. public void should_retrieve_private_primary_verified_email_address() {
  119. github.enqueue(newSuccessfulAccessTokenResponse());
  120. // response of api.github.com/user
  121. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}"));
  122. // response of api.github.com/user/emails
  123. github.enqueue(new MockResponse().setBody(
  124. "[\n" +
  125. " {\n" +
  126. " \"email\": \"support@github.com\",\n" +
  127. " \"verified\": false,\n" +
  128. " \"primary\": false\n" +
  129. " },\n" +
  130. " {\n" +
  131. " \"email\": \"octocat@github.com\",\n" +
  132. " \"verified\": true,\n" +
  133. " \"primary\": true\n" +
  134. " },\n" +
  135. "]"));
  136. HttpServletRequest request = newRequest("the-verifier-code");
  137. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  138. underTest.callback(callbackContext);
  139. assertThat(callbackContext.csrfStateVerified.get()).isTrue();
  140. assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
  141. assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("octocat");
  142. assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
  143. assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com");
  144. assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
  145. }
  146. @Test
  147. public void should_not_fail_if_no_email() {
  148. github.enqueue(newSuccessfulAccessTokenResponse());
  149. // response of api.github.com/user
  150. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}"));
  151. // response of api.github.com/user/emails
  152. github.enqueue(new MockResponse().setBody("[]"));
  153. HttpServletRequest request = newRequest("the-verifier-code");
  154. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  155. underTest.callback(callbackContext);
  156. assertThat(callbackContext.csrfStateVerified.get()).isTrue();
  157. assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
  158. assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("octocat");
  159. assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
  160. assertThat(callbackContext.userIdentity.getEmail()).isNull();
  161. assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
  162. }
  163. @Test
  164. public void redirect_browser_to_github_authentication_form_with_group_sync() throws Exception {
  165. settings.setProperty("sonar.auth.github.groupsSync", true);
  166. DumbInitContext context = new DumbInitContext("the-csrf-state");
  167. underTest.init(context);
  168. assertThat(context.redirectedTo).isEqualTo(
  169. gitHubSettings.webURL() +
  170. "login/oauth/authorize" +
  171. "?response_type=code" +
  172. "&client_id=the_id" +
  173. "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
  174. "&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) +
  175. "&state=the-csrf-state");
  176. }
  177. @Test
  178. public void callback_on_successful_authentication_with_group_sync() {
  179. settings.setProperty("sonar.auth.github.groupsSync", true);
  180. github.enqueue(newSuccessfulAccessTokenResponse());
  181. // response of api.github.com/user
  182. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
  183. // response of api.github.com/user/teams
  184. github.enqueue(new MockResponse().setBody("[\n" +
  185. " {\n" +
  186. " \"slug\": \"developers\",\n" +
  187. " \"organization\": {\n" +
  188. " \"login\": \"SonarSource\"\n" +
  189. " }\n" +
  190. " }\n" +
  191. "]"));
  192. HttpServletRequest request = newRequest("the-verifier-code");
  193. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  194. underTest.callback(callbackContext);
  195. assertThat(callbackContext.userIdentity.getGroups()).containsOnly("SonarSource/developers");
  196. }
  197. @Test
  198. public void callback_on_successful_authentication_with_group_sync_on_many_pages() {
  199. settings.setProperty("sonar.auth.github.groupsSync", true);
  200. github.enqueue(newSuccessfulAccessTokenResponse());
  201. // response of api.github.com/user
  202. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
  203. // responses of api.github.com/user/teams
  204. github.enqueue(new MockResponse()
  205. .setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"next\", <" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"last\"")
  206. .setBody("[\n" +
  207. " {\n" +
  208. " \"slug\": \"developers\",\n" +
  209. " \"organization\": {\n" +
  210. " \"login\": \"SonarSource\"\n" +
  211. " }\n" +
  212. " }\n" +
  213. "]"));
  214. github.enqueue(new MockResponse()
  215. .setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"prev\", <" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"first\"")
  216. .setBody("[\n" +
  217. " {\n" +
  218. " \"slug\": \"sonarsource-developers\",\n" +
  219. " \"organization\": {\n" +
  220. " \"login\": \"SonarQubeCommunity\"\n" +
  221. " }\n" +
  222. " }\n" +
  223. "]"));
  224. HttpServletRequest request = newRequest("the-verifier-code");
  225. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  226. underTest.callback(callbackContext);
  227. assertThat(new TreeSet<>(callbackContext.userIdentity.getGroups())).containsOnly("SonarQubeCommunity/sonarsource-developers", "SonarSource/developers");
  228. }
  229. @Test
  230. public void redirect_browser_to_github_authentication_form_with_organizations() throws Exception {
  231. settings.setProperty("sonar.auth.github.organizations", "example0, example1");
  232. DumbInitContext context = new DumbInitContext("the-csrf-state");
  233. underTest.init(context);
  234. assertThat(context.redirectedTo).isEqualTo(
  235. gitHubSettings.webURL() +
  236. "login/oauth/authorize" +
  237. "?response_type=code" +
  238. "&client_id=the_id" +
  239. "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
  240. "&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) +
  241. "&state=the-csrf-state");
  242. }
  243. @Test
  244. public void callback_on_successful_authentication_with_organizations_with_membership() {
  245. settings.setProperty("sonar.auth.github.organizations", "example0, example1");
  246. github.enqueue(newSuccessfulAccessTokenResponse());
  247. // response of api.github.com/user
  248. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
  249. // response of api.github.com/orgs/example0/members/user
  250. github.enqueue(new MockResponse().setResponseCode(204));
  251. HttpServletRequest request = newRequest("the-verifier-code");
  252. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  253. underTest.callback(callbackContext);
  254. assertThat(callbackContext.csrfStateVerified.get()).isTrue();
  255. assertThat(callbackContext.userIdentity).isNotNull();
  256. assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
  257. }
  258. @Test
  259. public void callback_on_successful_authentication_with_organizations_without_membership() {
  260. settings.setProperty("sonar.auth.github.organizations", "first_org,second_org");
  261. github.enqueue(newSuccessfulAccessTokenResponse());
  262. // response of api.github.com/user
  263. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
  264. // response of api.github.com/orgs/first_org/members/user
  265. github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
  266. // response of api.github.com/orgs/second_org/members/user
  267. github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
  268. HttpServletRequest request = newRequest("the-verifier-code");
  269. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  270. try {
  271. underTest.callback(callbackContext);
  272. fail("exception expected");
  273. } catch (UnauthorizedException e) {
  274. assertThat(e.getMessage()).isEqualTo("'octocat' must be a member of at least one organization: 'first_org', 'second_org'");
  275. }
  276. }
  277. @Test
  278. public void callback_throws_ISE_if_error_when_requesting_user_profile() {
  279. github.enqueue(newSuccessfulAccessTokenResponse());
  280. // api.github.com/user crashes
  281. github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}"));
  282. DumbCallbackContext callbackContext = new DumbCallbackContext(newRequest("the-verifier-code"));
  283. try {
  284. underTest.callback(callbackContext);
  285. fail("exception expected");
  286. } catch (IllegalStateException e) {
  287. assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "user'. HTTP code: 500, response: {error}");
  288. }
  289. assertThat(callbackContext.csrfStateVerified.get()).isTrue();
  290. assertThat(callbackContext.userIdentity).isNull();
  291. assertThat(callbackContext.redirectedToRequestedPage.get()).isFalse();
  292. }
  293. @Test
  294. public void callback_throws_ISE_if_error_when_checking_membership() {
  295. settings.setProperty("sonar.auth.github.organizations", "example");
  296. github.enqueue(newSuccessfulAccessTokenResponse());
  297. // response of api.github.com/user
  298. github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
  299. // crash of api.github.com/orgs/example/members/user
  300. github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}"));
  301. HttpServletRequest request = newRequest("the-verifier-code");
  302. DumbCallbackContext callbackContext = new DumbCallbackContext(request);
  303. try {
  304. underTest.callback(callbackContext);
  305. fail("exception expected");
  306. } catch (IllegalStateException e) {
  307. assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "orgs/example/members/octocat'. HTTP code: 500, response: {error}");
  308. }
  309. }
  310. /**
  311. * Response sent by GitHub to SonarQube when generating an access token
  312. */
  313. private static MockResponse newSuccessfulAccessTokenResponse() {
  314. // github does not return the standard JSON format but plain-text
  315. // see https://developer.github.com/v3/oauth/
  316. return new MockResponse().setBody("access_token=e72e16c7e42f292c6912e7710c838347ae178b4a&scope=user%2Cgist&token_type=bearer");
  317. }
  318. private static HttpServletRequest newRequest(String verifierCode) {
  319. HttpServletRequest request = mock(HttpServletRequest.class);
  320. when(request.getParameter("code")).thenReturn(verifierCode);
  321. return request;
  322. }
  323. private static class DumbCallbackContext implements OAuth2IdentityProvider.CallbackContext {
  324. final HttpServletRequest request;
  325. final AtomicBoolean csrfStateVerified = new AtomicBoolean(false);
  326. final AtomicBoolean redirectedToRequestedPage = new AtomicBoolean(false);
  327. UserIdentity userIdentity = null;
  328. public DumbCallbackContext(HttpServletRequest request) {
  329. this.request = request;
  330. }
  331. @Override
  332. public void verifyCsrfState() {
  333. this.csrfStateVerified.set(true);
  334. }
  335. @Override
  336. public void verifyCsrfState(String parameterName) {
  337. throw new UnsupportedOperationException("not used");
  338. }
  339. @Override
  340. public void redirectToRequestedPage() {
  341. redirectedToRequestedPage.set(true);
  342. }
  343. @Override
  344. public void authenticate(UserIdentity userIdentity) {
  345. this.userIdentity = userIdentity;
  346. }
  347. @Override
  348. public String getCallbackUrl() {
  349. return CALLBACK_URL;
  350. }
  351. @Override
  352. public HttpServletRequest getRequest() {
  353. return request;
  354. }
  355. @Override
  356. public HttpServletResponse getResponse() {
  357. throw new UnsupportedOperationException("not used");
  358. }
  359. }
  360. private static class DumbInitContext implements OAuth2IdentityProvider.InitContext {
  361. String redirectedTo = null;
  362. private final String generatedCsrfState;
  363. public DumbInitContext(String generatedCsrfState) {
  364. this.generatedCsrfState = generatedCsrfState;
  365. }
  366. @Override
  367. public String generateCsrfState() {
  368. return generatedCsrfState;
  369. }
  370. @Override
  371. public void redirectTo(String url) {
  372. this.redirectedTo = url;
  373. }
  374. @Override
  375. public String getCallbackUrl() {
  376. return CALLBACK_URL;
  377. }
  378. @Override
  379. public HttpServletRequest getRequest() {
  380. return null;
  381. }
  382. @Override
  383. public HttpServletResponse getResponse() {
  384. return null;
  385. }
  386. }
  387. }