diff --git a/src/main/java/hudson/plugins/jira/JiraRestService.java b/src/main/java/hudson/plugins/jira/JiraRestService.java index 592fe7ab2..a64e5dd24 100644 --- a/src/main/java/hudson/plugins/jira/JiraRestService.java +++ b/src/main/java/hudson/plugins/jira/JiraRestService.java @@ -116,7 +116,19 @@ public JiraRestService(URI uri, ExtendedJiraRestClient jiraRestClient, String us throw new RuntimeException("failed to encode username:password using Base64"); } this.jiraRestClient = jiraRestClient; + baseApiPath = buildBaseApiPath(uri); + } + + public JiraRestService(URI uri, ExtendedJiraRestClient jiraRestClient, String token, int timeout) { + this.uri = uri; + this.objectMapper = new ObjectMapper(); + this.timeout = timeout; + this.authHeader = "Bearer " + token; + this.jiraRestClient = jiraRestClient; + baseApiPath = buildBaseApiPath(uri); + } + private String buildBaseApiPath(URI uri) { final StringBuilder builder = new StringBuilder(); if (uri.getPath() != null) { builder.append(uri.getPath()); @@ -127,7 +139,7 @@ public JiraRestService(URI uri, ExtendedJiraRestClient jiraRestClient, String us builder.append('/'); } builder.append(BASE_API_PATH); - baseApiPath = builder.toString(); + return builder.toString(); } public void addComment(String issueId, String commentBody, diff --git a/src/main/java/hudson/plugins/jira/JiraSessionFactory.java b/src/main/java/hudson/plugins/jira/JiraSessionFactory.java new file mode 100644 index 000000000..c505980cd --- /dev/null +++ b/src/main/java/hudson/plugins/jira/JiraSessionFactory.java @@ -0,0 +1,70 @@ +package hudson.plugins.jira; + +import java.net.URI; + +import com.atlassian.jira.rest.client.auth.BasicHttpAuthenticationHandler; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; + +import hudson.plugins.jira.JiraSite.ExtendedAsynchronousJiraRestClientFactory; +import hudson.plugins.jira.auth.BearerHttpAuthenticationHandler; +import hudson.plugins.jira.extension.ExtendedJiraRestClient; + +/** + * Jira Session factory implementation + * + * @author Elia Bracci + */ +public class JiraSessionFactory { + + /** + * This method takes as parameters the JiraSite class, the jira URI and + * credentials and returns a JiraSession with Basic authentication if + * useBearerAuth is set to false, otherwise it returns a JiraSession with Bearer + * authentication if useBearerAuth is set to true. + * + * @param jiraSite jiraSite class + * @param uri jira uri + * @param credentials Jenkins credentials + * @return JiraSession instance + */ + public static JiraSession create(JiraSite jiraSite, URI uri, + StandardUsernamePasswordCredentials credentials) { + ExtendedJiraRestClient jiraRestClient; + JiraRestService jiraRestService; + + if (jiraSite.isUseBearerAuth()) { + BearerHttpAuthenticationHandler bearerHttpAuthenticationHandler = new BearerHttpAuthenticationHandler( + credentials.getPassword().getPlainText()); + + jiraRestClient = new ExtendedAsynchronousJiraRestClientFactory() + .create( + uri, + bearerHttpAuthenticationHandler, + jiraSite.getHttpClientOptions()); + + jiraRestService = new JiraRestService( + uri, + jiraRestClient, + credentials.getPassword().getPlainText(), + jiraSite.getReadTimeout()); + } else { + jiraRestClient = new ExtendedAsynchronousJiraRestClientFactory() + .create( + uri, + new BasicHttpAuthenticationHandler( + credentials.getUsername(), + credentials.getPassword().getPlainText()), + jiraSite.getHttpClientOptions()); + + jiraRestService = new JiraRestService( + uri, + jiraRestClient, + credentials.getUsername(), + credentials.getPassword().getPlainText(), + jiraSite.getReadTimeout()); + } + + return new JiraSession(jiraSite, jiraRestService); + } + +} diff --git a/src/main/java/hudson/plugins/jira/JiraSite.java b/src/main/java/hudson/plugins/jira/JiraSite.java index 569cb821c..c7d4e527c 100644 --- a/src/main/java/hudson/plugins/jira/JiraSite.java +++ b/src/main/java/hudson/plugins/jira/JiraSite.java @@ -34,6 +34,7 @@ import hudson.plugins.jira.extension.ExtendedJiraRestClient; import hudson.plugins.jira.extension.ExtendedVersion; import hudson.plugins.jira.model.JiraIssue; +import hudson.plugins.jira.JiraSessionFactory; import hudson.security.ACL; import hudson.util.FormValidation; import hudson.util.ListBoxModel; @@ -139,6 +140,11 @@ public class JiraSite extends AbstractDescribableImpl { */ public String credentialsId; + /** + * Jira requires Bearer Authentication for login + */ + public boolean useBearerAuth; + /** * User name needed to login. Optional. * @deprecated use credentialsId @@ -322,6 +328,15 @@ public JiraSite(URL url, URL alternativeUrl, StandardUsernamePasswordCredentials updateJiraIssueForAllStatus, groupVisibility, roleVisibility, useHTTPAuth, timeout, readTimeout, threadExecutorNumber); } + // Deprecate the previous constructor but leave it in place for Java-level compatibility. + @Deprecated + public JiraSite(URL url, URL alternativeUrl, StandardUsernamePasswordCredentials credentials, boolean supportsWikiStyleComment, boolean recordScmChanges, String userPattern, + boolean updateJiraIssueForAllStatus, String groupVisibility, String roleVisibility, boolean useHTTPAuth, int timeout, int readTimeout, int threadExecutorNumber, boolean useBearerAuth) { + this(url, alternativeUrl, credentials==null?null:credentials.getId(), supportsWikiStyleComment, recordScmChanges, userPattern, + updateJiraIssueForAllStatus, groupVisibility, roleVisibility, useHTTPAuth, timeout, readTimeout, threadExecutorNumber); + this.useBearerAuth = useBearerAuth; + } + static URL toURL(String url) { url = Util.fixEmptyAndTrim(url); if (url == null) return null; @@ -414,6 +429,10 @@ public boolean isUseHTTPAuth() { return useHTTPAuth; } + public boolean isUseBearerAuth() { + return useBearerAuth; + } + public String getGroupVisibility() { return groupVisibility; } @@ -444,6 +463,11 @@ public void setUseHTTPAuth(boolean useHTTPAuth) { this.useHTTPAuth = useHTTPAuth; } + @DataBoundSetter + public void setUseBearerAuth(boolean useBearerAuth) { + this.useBearerAuth = useBearerAuth; + } + @DataBoundSetter public void setGroupVisibility(String groupVisibility) { this.groupVisibility = Util.fixEmptyAndTrim(groupVisibility); @@ -553,14 +577,7 @@ JiraSession createSession(Item item) { } LOGGER.fine("creating Jira Session: " + uri); - ExtendedJiraRestClient jiraRestClient = new ExtendedAsynchronousJiraRestClientFactory() - .create(uri, new BasicHttpAuthenticationHandler( - credentials.getUsername(), credentials.getPassword().getPlainText() - ), - getHttpClientOptions() - ); - return new JiraSession(this, new JiraRestService(uri, jiraRestClient, credentials.getUsername(), - credentials.getPassword().getPlainText(), readTimeout)); + return JiraSessionFactory.create(this, uri, credentials); } Lock getProjectUpdateLock() { @@ -599,7 +616,7 @@ private StandardUsernamePasswordCredentials resolveCredentials(Item item) { } - private HttpClientOptions getHttpClientOptions() { + protected HttpClientOptions getHttpClientOptions() { final HttpClientOptions options = new HttpClientOptions(); options.setRequestTimeout(readTimeout, TimeUnit.SECONDS); options.setSocketTimeout(timeout, TimeUnit.SECONDS); @@ -1161,6 +1178,7 @@ public FormValidation doValidate(@QueryParameter String url, @QueryParameter int timeout, @QueryParameter int readTimeout, @QueryParameter int threadExecutorNumber, + @QueryParameter boolean useBearerAuth, @AncestorInPath Item item) { if (item == null) { @@ -1213,6 +1231,7 @@ public FormValidation doValidate(@QueryParameter String url, site.setTimeout(timeout); site.setReadTimeout(readTimeout); site.setThreadExecutorNumber(threadExecutorNumber); + site.setUseBearerAuth(useBearerAuth); JiraSession session = null; try { session = site.getSession(item); diff --git a/src/main/java/hudson/plugins/jira/auth/BearerHttpAuthenticationHandler.java b/src/main/java/hudson/plugins/jira/auth/BearerHttpAuthenticationHandler.java new file mode 100644 index 000000000..ff4043dcd --- /dev/null +++ b/src/main/java/hudson/plugins/jira/auth/BearerHttpAuthenticationHandler.java @@ -0,0 +1,29 @@ +package hudson.plugins.jira.auth; + +import com.atlassian.jira.rest.client.api.AuthenticationHandler; +import com.atlassian.httpclient.api.Request.Builder; + +/** + * Authentication handler for bearer authentication + * + * @author Elia Bracci + */ +public class BearerHttpAuthenticationHandler implements AuthenticationHandler { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private final String token; + + /** + * Bearer http authentication handler constructor + * @param token pat or api token to use for bearer authentication + */ + public BearerHttpAuthenticationHandler(final String token) { + this.token = token; + } + + + @Override + public void configure(Builder builder) { + builder.setHeader(AUTHORIZATION_HEADER, "Bearer " + token); + } +} \ No newline at end of file diff --git a/src/main/resources/hudson/plugins/jira/JiraSite/config.jelly b/src/main/resources/hudson/plugins/jira/JiraSite/config.jelly index 2d56f7098..7e939f4e4 100644 --- a/src/main/resources/hudson/plugins/jira/JiraSite/config.jelly +++ b/src/main/resources/hudson/plugins/jira/JiraSite/config.jelly @@ -6,8 +6,11 @@ - + + + + @@ -50,7 +53,7 @@ + method="validate" with="url,credentialsId,groupVisibility,roleVisibility,useHTTPAuth,alternativeUrl,timeout,readTimeout,threadExecutorNumber,useBearerAuth" />
diff --git a/src/main/resources/hudson/plugins/jira/JiraSite/config.properties b/src/main/resources/hudson/plugins/jira/JiraSite/config.properties index 9f227308f..ed3965082 100644 --- a/src/main/resources/hudson/plugins/jira/JiraSite/config.properties +++ b/src/main/resources/hudson/plugins/jira/JiraSite/config.properties @@ -1,2 +1,3 @@ site.alternativeUrl=Jira alternative URL site.timeout=in seconds +site.useBearerAuth=Note: Bearer authentication is only supported in Jira Server, for Jira Cloud leave this unchecked \ No newline at end of file diff --git a/src/test/java/JiraConfig.java b/src/test/java/JiraConfig.java index 3e3d526ee..62008c3a1 100644 --- a/src/test/java/JiraConfig.java +++ b/src/test/java/JiraConfig.java @@ -16,4 +16,8 @@ public static String getUsername() { public static String getPassword() { return CONFIG.getString("password"); } + + public static String getToken() { + return CONFIG.getString("token"); + } } diff --git a/src/test/java/JiraTesterBearerAuth.java b/src/test/java/JiraTesterBearerAuth.java new file mode 100644 index 000000000..4b1b9db71 --- /dev/null +++ b/src/test/java/JiraTesterBearerAuth.java @@ -0,0 +1,123 @@ + +import com.atlassian.jira.rest.client.api.domain.Component; +import com.atlassian.jira.rest.client.api.domain.Issue; +import com.atlassian.jira.rest.client.api.domain.IssueType; +import com.atlassian.jira.rest.client.api.domain.Status; +import com.atlassian.jira.rest.client.api.domain.Transition; +import com.atlassian.jira.rest.client.api.domain.User; +import hudson.plugins.jira.JiraRestService; +import hudson.plugins.jira.JiraSite; +import hudson.plugins.jira.auth.BearerHttpAuthenticationHandler; +import hudson.plugins.jira.extension.ExtendedJiraRestClient; +import hudson.plugins.jira.extension.ExtendedVersion; + +import java.net.URI; +import java.net.URL; +import java.util.List; + +import static hudson.plugins.jira.JiraSite.ExtendedAsynchronousJiraRestClientFactory; + +/** + * Test bed to play with Jira. + * + * @author Elia Bracci + */ +public class JiraTesterBearerAuth { + public static void main(String[] args) throws Exception { + + final URI uri = new URL(JiraConfig.getUrl()).toURI(); + final BearerHttpAuthenticationHandler handler = new BearerHttpAuthenticationHandler(JiraConfig.getToken()); + final ExtendedJiraRestClient jiraRestClient = new ExtendedAsynchronousJiraRestClientFactory() + .createWithAuthenticationHandler(uri, handler); + + final JiraRestService restService = new JiraRestService(uri, jiraRestClient, JiraConfig.getToken(), JiraSite.DEFAULT_TIMEOUT); + + final String projectKey = "TESTPROJECT"; + final String issueId = "TESTPROJECT-425"; + final Integer actionId = 21; + + final Issue issue = restService.getIssue(issueId); + System.out.println("issue:" + issue); + + + final List availableActions = restService.getAvailableActions(issueId); + for (Transition action : availableActions) { + System.out.println("Action:" + action); + } + + for (IssueType issueType : restService.getIssueTypes()) { + System.out.println(" issue type: " + issueType); + } + +// restService.addVersion("TESTPROJECT", "0.0.2"); + + final List components = restService.getComponents(projectKey); + for (Component component : components) { + System.out.println("component: " + component); + } + +// BasicComponent backendComponent = null; +// final Iterable components1 = Lists.newArrayList(backendComponent); +// restService.createIssue("TESTPROJECT", "This is a test issue created using Jira jenkins plugin. Please ignore it.", "TESTUSER", components1, "test issue from Jira jenkins plugin"); + + final List searchResults = restService.getIssuesFromJqlSearch("project = \"TESTPROJECT\"", 3); + for (Issue searchResult : searchResults) { + System.out.println("JQL search result: " + searchResult); + } + + final List projectsKeys = restService.getProjectsKeys(); + for (String projectsKey : projectsKeys) { + System.out.println("project key: " + projectsKey); + } + + final List statuses = restService.getStatuses(); + for (Status status : statuses) { + System.out.println("status:" + status); + } + + final User user = restService.getUser("TESTUSER"); + System.out.println("user: " + user); + + final List versions = restService.getVersions(projectKey); + for (ExtendedVersion version : versions) { + System.out.println("version: " + version); + } + +// Version releaseVersion = new Version(version.getSelf(), version.getId(), version.getName(), +// version.getDescription(), version.isArchived(), true, new DateTime()); +// System.out.println(" >>>> release version 0.0.2"); +// restService.releaseVersion("TESTPROJECT", releaseVersion); + +// System.out.println(" >>> update issue TESTPROJECT-425"); +// restService.updateIssue(issueId, Collections.singletonList(releaseVersion)); + +// final Issue updatedIssue = restService.progressWorkflowAction(issueId, actionId); +// System.out.println("Updated issue:" + updatedIssue); + + + + for(int i=0;i<10;i++){ + callUniq( restService ); + } + + for(int i=0;i<10;i++){ + callDuplicate( restService ); + } + + } + + private static void callUniq(final JiraRestService restService) throws Exception { + long start = System.currentTimeMillis(); + List issues = restService.getIssuesFromJqlSearch( "key in ('JENKINS-53320','JENKINS-51057')", Integer.MAX_VALUE ); + long end = System.currentTimeMillis(); + System.out.println( "time uniq " + (end -start) ); + } + + private static void callDuplicate(final JiraRestService restService) throws Exception { + long start = System.currentTimeMillis(); + List issues = restService.getIssuesFromJqlSearch( "key in ('JENKINS-53320','JENKINS-53320','JENKINS-53320','JENKINS-53320','JENKINS-53320','JENKINS-51057','JENKINS-51057','JENKINS-51057','JENKINS-51057','JENKINS-51057')", Integer.MAX_VALUE ); + long end = System.currentTimeMillis(); + System.out.println( "time duplicate " + (end -start) ); + } + +} diff --git a/src/test/java/hudson/plugins/jira/DescriptorImplTest.java b/src/test/java/hudson/plugins/jira/DescriptorImplTest.java index 8f483a7c0..f31c5eff9 100644 --- a/src/test/java/hudson/plugins/jira/DescriptorImplTest.java +++ b/src/test/java/hudson/plugins/jira/DescriptorImplTest.java @@ -137,7 +137,7 @@ public void validateFormConnectionErrors() throws Exception { FormValidation validation = descriptor.doValidate("http://localhost:8080", null, null, null, false, null, JiraSite.DEFAULT_TIMEOUT, JiraSite.DEFAULT_READ_TIMEOUT, JiraSite.DEFAULT_THREAD_EXECUTOR_NUMBER, - project); + false, project); assertEquals(FormValidation.Kind.ERROR, validation.kind); verify(site).getSession(project); @@ -145,7 +145,7 @@ public void validateFormConnectionErrors() throws Exception { validation = descriptor.doValidate("http://localhost:8080", null, null, null, false, null, -1, JiraSite.DEFAULT_READ_TIMEOUT, JiraSite.DEFAULT_THREAD_EXECUTOR_NUMBER, - project); + false, project); assertEquals(Messages.JiraSite_timeoutMinimunValue("1"), validation.getLocalizedMessage()); assertEquals(FormValidation.Kind.ERROR, validation.kind); verify(site).getSession(project); @@ -153,7 +153,7 @@ public void validateFormConnectionErrors() throws Exception { validation = descriptor.doValidate("http://localhost:8080", null, null, null, false, null, JiraSite.DEFAULT_TIMEOUT, -1, JiraSite.DEFAULT_THREAD_EXECUTOR_NUMBER, - project); + false, project); assertEquals(Messages.JiraSite_readTimeoutMinimunValue("1"), validation.getMessage()); assertEquals(FormValidation.Kind.ERROR, validation.kind); @@ -162,7 +162,7 @@ public void validateFormConnectionErrors() throws Exception { validation = descriptor.doValidate("http://localhost:8080", null, null, null, false, null, JiraSite.DEFAULT_TIMEOUT, JiraSite.DEFAULT_READ_TIMEOUT, -1, - project); + false, project); assertEquals(Messages.JiraSite_threadExecutorMinimunSize("1"), validation.getMessage()); assertEquals(FormValidation.Kind.ERROR, validation.kind); verify(site).getSession(project); @@ -180,7 +180,7 @@ public void validateFormConnectionOK() throws Exception { FormValidation validation = descriptor.doValidate("http://localhost:8080", null, null, null, false, null, JiraSite.DEFAULT_TIMEOUT, JiraSite.DEFAULT_READ_TIMEOUT, JiraSite.DEFAULT_THREAD_EXECUTOR_NUMBER, - project); + false, project); verify(builder).build(); verify(site).getSession(project); diff --git a/src/test/java/hudson/plugins/jira/auth/BearerHttpAuthenticationHandlerTest.java b/src/test/java/hudson/plugins/jira/auth/BearerHttpAuthenticationHandlerTest.java new file mode 100644 index 000000000..4e3c966fe --- /dev/null +++ b/src/test/java/hudson/plugins/jira/auth/BearerHttpAuthenticationHandlerTest.java @@ -0,0 +1,21 @@ +package hudson.plugins.jira.auth; + +import com.atlassian.httpclient.api.Request; +import org.junit.Test; +import static org.mockito.Mockito.mock; + +import static org.mockito.Mockito.verify; + +public class BearerHttpAuthenticationHandlerTest { + + @Test + public void testConfigure() { + String token = "token"; + BearerHttpAuthenticationHandler handler = new BearerHttpAuthenticationHandler(token); + Request.Builder builder = mock(Request.Builder.class); + + handler.configure(builder); + + verify(builder).setHeader("Authorization", "Bearer " + token); + } +} \ No newline at end of file diff --git a/src/test/java/hudson/plugins/jira/auth/JiraRestServiceBearerAuthTest.java b/src/test/java/hudson/plugins/jira/auth/JiraRestServiceBearerAuthTest.java new file mode 100644 index 000000000..e8f7181fe --- /dev/null +++ b/src/test/java/hudson/plugins/jira/auth/JiraRestServiceBearerAuthTest.java @@ -0,0 +1,63 @@ +package hudson.plugins.jira.auth; + +import com.atlassian.jira.rest.client.api.SearchRestClient; +import com.atlassian.jira.rest.client.api.domain.SearchResult; +import io.atlassian.util.concurrent.Promise; +import hudson.plugins.jira.JiraRestService; +import hudson.plugins.jira.JiraSite; +import hudson.plugins.jira.extension.ExtendedJiraRestClient; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import java.net.URI; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.doReturn; + +public class JiraRestServiceBearerAuthTest { + + private final URI JIRA_URI = URI.create("http://example.com:8080/"); + private final String TOKEN = "token"; + private ExtendedJiraRestClient client; + private SearchRestClient searchRestClient; + private Promise promise; + private SearchResult searchResult; + + @Before + public void createMocks() throws InterruptedException, ExecutionException, TimeoutException { + client = mock(ExtendedJiraRestClient.class); + searchRestClient = mock(SearchRestClient.class); + promise = mock(Promise.class); + searchResult = mock(SearchResult.class); + + doReturn(searchRestClient).when(client).getSearchClient(); + doReturn(promise).when(searchRestClient).searchJql(any(), any(), anyInt(), any()); + doReturn(searchResult).when(promise).get(anyLong(), any()); + } + + @Test + public void baseApiPath() { + JiraRestService service = new JiraRestService(JIRA_URI, client, TOKEN, JiraSite.DEFAULT_TIMEOUT); + assertEquals("/" + JiraRestService.BASE_API_PATH, service.getBaseApiPath()); + + URI uri = URI.create("https://example.com/path/to/jira"); + service = new JiraRestService(uri, client, TOKEN, JiraSite.DEFAULT_TIMEOUT); + assertEquals("/path/to/jira/" + JiraRestService.BASE_API_PATH, service.getBaseApiPath()); + } + + @Test(expected = TimeoutException.class) + public void getIssuesFromJqlSearchTimeout() throws TimeoutException, InterruptedException, ExecutionException { + JiraRestService service = spy(new JiraRestService(JIRA_URI, client, TOKEN, JiraSite.DEFAULT_TIMEOUT)); + doThrow(new TimeoutException()).when(promise).get(Mockito.anyLong(), Mockito.any()); + service.getIssuesFromJqlSearch("*", null); + } +} diff --git a/src/test/resources/jira.properties b/src/test/resources/jira.properties index 1da1c3f58..506185cf6 100644 --- a/src/test/resources/jira.properties +++ b/src/test/resources/jira.properties @@ -1,3 +1,4 @@ url=http://host/jira/rpc/soap/jirasoapservice-v2 username=user -password=passwd \ No newline at end of file +password=passwd +token=token \ No newline at end of file