Skip to content

Commit

Permalink
Merge pull request #195 from martinda/fix-crumb-issue
Browse files Browse the repository at this point in the history
This is a big PR with lots of features. They all look sane enough. I trust you did the testing on your end to confirm all is OK.
  • Loading branch information
cdancy authored Jan 14, 2022
2 parents bda3401 + 2679337 commit 7bed241
Show file tree
Hide file tree
Showing 31 changed files with 1,231 additions and 177 deletions.
41 changes: 33 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,37 @@ Setting the `endpoint` can be done with any of the following (searched in order)
- `jenkinsRestEndpoint`
- `JENKINS_REST_ENDPOINT`

When none is found, the endpoint is set to `http://localhost:8080`.

Setting the `credentials` can be done with any of the following (searched in order):

- `jenkins.rest.api.token`
- `jenkinsRestApiToken`
- `JENKINS_REST_API_TOKEN`
- `jenkins.rest.credentials`
- `jenkinsRestCredentials`
- `JENKINS_REST_CREDENTIALS`

When none is found, no authentication is used (anonymous).

## Credentials

jenkins-rest credentials can take 1 of 2 forms:
jenkins-rest credentials can take 1 of 3 forms:

- Colon delimited username and api token: __admin:apiToken__
- use `JenkinsClient.builder().apiToken("admin:apiToken")`
- Colon delimited username and password: __admin:password__
- use `JenkinsClient.builder().credentials("admin:password")`
- Base64 encoded username followed by password __YWRtaW46cGFzc3dvcmQ=__ or api token __YWRtaW46YXBpVG9rZW4=__
- use `JenkinsClient.builder().credentials("YWRtaW46cGFzc3dvcmQ=")`
- use `JenkinsClient.builder().apiToken("YWRtaW46YXBpVG9rZW4=")`

The Jenkins crumb is automatically requested when POSTing using the anonymous and the username:password authentication methods.
It is not requested when you use the apiToken as it is not needed in this case.
For more details, see

- Colon delimited username and password: __admin:password__
- Base64 encoded username and password: __YWRtaW46cGFzc3dvcmQ=__
* [CSRF Protection on jenkins.io](https://www.jenkins.io/doc/book/security/csrf-protection/)
* [Cloudbees crumb documentation](https://support.cloudbees.com/hc/en-us/articles/219257077-CSRF-Protection-Explained).

## Examples

Expand All @@ -80,39 +99,45 @@ Running mock tests can be done like so:

./gradlew clean build mockTest

Running integration tests can be done like so (requires existing jenkins instance):
Running integration tests require an existing jenkins instance which can be obtained with docker:

docker build -t jenkins-rest/jenkins src/main/docker
docker run -d --rm -p 8080:8080 --name jenkins-rest jenkins-rest/jenkins
./gradlew clean build integTest

### Integration tests settings

If you use the provided docker instance, there is no other preparation necessary.
If you wish to run integration tests against your own Jenkins server, the requirements are outlined in the next section.

#### Jenkins instance requirements

- a running instance accessible on http://127.0.0.1:8080 (can be changed in the gradle.properties file)
- Jenkins security
- Authorization: Anyone can do anything (to be able to test the crumb with the anonymous account)
- an `admin` user (credentials used by the tests can be changed in the gradle.properties file) with `ADMIN` role (required as the tests install plugins)
- [CSRF protection enabled](https://wiki.jenkins.io/display/JENKINS/CSRF+Protection). Not mandatory but [recommended by the Jenkins documentation](https://jenkins.io/doc/book/system-administration/security/#protect-users-of-jenkins-from-other-threats). The lib supports Jenkins instances with our without this protection (see #14)
- Plugins
- [CloudBees Credentials](https://plugins.jenkins.io/cloudbees-credentials): otherwise an http 500 error occurs when accessing
to http://127.0.0.1:8080/job/test-folder/job/test-folder-1/ `java.lang.NoClassDefFoundError: com/cloudbees/hudson/plugins/folder/properties/FolderCredentialsProvider`
- [CloudBees Folder](https://plugins.jenkins.io/cloudbees-folder) plugin installed
- [OWASP Markup Formatter](https://plugins.jenkins.io/antisamy-markup-formatter) configured to use `Safe HTML`
- [Configuration As Code](https://plugins.jenkins.io/configuration-as-code) plugin installed
- No Jenkins Jobs configured, no folder. The instance must be empty.

This project provides instructions to setup a [pre-configured Docker container](src/main/docker/README.md)

#### Integration tests configuration

- jenkins url and authentication method used by the tests are defined in the `gradle.properties` file
- by default, tests use the `credentials` authentication but this can be changed to use the `token` authentication

- by default, tests use the `credentials` (username:password) authentication method but this can be changed to use the API Token. See the `gradle.properties` file.

#### Running integration tests from within your IDE

- the `integTest` gradle tasks set various System Properties
- the `integTest` gradle task sets various System Properties
- if you don't want to use gradle as tests runner in your IDE, configure the tests with the same kind of System Properties



# Additional Resources

* [Jenkins REST API](http://wiki.jenkins-ci.org/display/JENKINS/Remote+access+API)
Expand Down
28 changes: 13 additions & 15 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ tasks.withType(JavaCompile) {
}

task mockTest(type: Test) {
group = "Verification"
description = "Mock tests"
useTestNG()
include '**/**MockTest*'
maxParallelForks = 2
Expand All @@ -70,6 +72,8 @@ task mockTest(type: Test) {
}

task integTest(type: Test, dependsOn: mockTest) {
group = "Verification"
description = "Integration tests - Jenkins must be running. See the README."
doFirst {
def integProjectDir = project.file("${buildDir}/integ-projects")
if (!integProjectDir.exists()) {
Expand All @@ -85,21 +89,15 @@ task integTest(type: Test, dependsOn: mockTest) {
showStandardStreams = true
events 'started', 'passed', 'failed'
}
def authentication = [:]
def possibleAuth = project.findProperty('testJenkinsRestCredentials')
if (possibleAuth) {
authentication['test.jenkins.rest.credentials'] = possibleAuth
} else {
possibleAuth = project.findProperty('testJenkinsRestToken')
if (possibleAuth) {
authentication['test.jenkins.rest.token'] = possibleAuth
} else {
logger.quiet 'No authentication parameters found. Assuming anonymous...'
}

def possibleUsernameApiToken = project.findProperty("testJenkinsUsernameApiToken")
def usernameApiToken = [:]
if (possibleUsernameApiToken) {
usernameApiToken["test.jenkins.usernameApiToken"] = possibleUsernameApiToken
}

// property 'test.bitbucket.endpoint' needs to be
// hard-coded in for jclouds test framework
systemProperties = ["test.jenkins.endpoint" : "${testJenkinsRestEndpoint}",
"test.jenkins.basedir" : "${buildDir}/integ-projects"] << authentication
systemProperties = [
"test.jenkins.endpoint" : testJenkinsRestEndpoint,
"test.jenkins.usernamePassword" : testJenkinsUsernamePassword
] << usernameApiToken
}
8 changes: 6 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ snapshotRepository = libs-snapshot-local
releaseRepository = libs-release-local

testJenkinsRestEndpoint = http://127.0.0.1:8080
testJenkinsRestCredentials = admin:admin
#testJenkinsRestToken
testJenkinsUsernamePassword = admin:admin

# Next line is optional.
# Use it if you want to provide your own account with a pre-existing API Token,
# instead of letting the test generate its own API Token.
#testJenkinsUsernameApiToken = "admin:fixme"

githubUsername=fixme
githubPassword=fixme
Expand Down
4 changes: 0 additions & 4 deletions src/main/docker/init.groovy.d/Security.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ instance.setCrumbIssuer(new DefaultCrumbIssuer(true))
def jenkinsRealm = new HudsonPrivateSecurityRealm(false)
jenkinsRealm.createAccount('admin', 'admin')
instance.setSecurityRealm(jenkinsRealm)
def strategy = new FullControlOnceLoggedInAuthorizationStrategy()
strategy.setAllowAnonymousRead(true)
instance.setAuthorizationStrategy(strategy)


// =====================================================================================================================
// Save everything
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/cdancy/jenkins/rest/JenkinsApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import com.cdancy.jenkins.rest.features.QueueApi;
import com.cdancy.jenkins.rest.features.StatisticsApi;
import com.cdancy.jenkins.rest.features.SystemApi;
import com.cdancy.jenkins.rest.features.UserApi;

public interface JenkinsApi extends Closeable {

Expand All @@ -51,4 +52,7 @@ public interface JenkinsApi extends Closeable {

@Delegate
ConfigurationAsCodeApi configurationAsCodeApi();

@Delegate
UserApi userApi();
}
96 changes: 68 additions & 28 deletions src/main/java/com/cdancy/jenkins/rest/JenkinsAuthentication.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,48 @@
import static com.google.common.io.BaseEncoding.base64;

import com.cdancy.jenkins.rest.auth.AuthenticationType;
import com.cdancy.jenkins.rest.exception.UndetectableIdentityException;

import java.nio.charset.StandardCharsets;
import java.util.Objects;

import org.jclouds.domain.Credentials;
import org.jclouds.javax.annotation.Nullable;

/**
* Credentials instance for Jenkins authentication.
* Credentials instance for Jenkins authentication.
*/
public class JenkinsAuthentication extends Credentials {

private final AuthenticationType authType;

/**
* Create instance of JenkinsAuthentication
*
* @param authValue value to use for authentication type HTTP header.
* @param authType authentication type (e.g. Basic, Bearer, Anonymous).
* Create instance of JenkinsAuthentication.
*
* @param identity the identity of the credential, this would be the username for the password or the api token or the base64 encoded value.
* @param credential the username:password, or the username:apiToken, or their base64 encoded value. This is base64 encoded before being stored.
* @param authType authentication type (e.g. UsernamePassword, UsernameApiToken, Anonymous).
*/
private JenkinsAuthentication(final String authValue, final AuthenticationType authType) {
super(null, authType == AuthenticationType.Basic && authValue.contains(":")
? base64().encode(authValue.getBytes())
: authValue);
this.authType = authType;
private JenkinsAuthentication(final String identity, final String credential, final AuthenticationType authType) {
super(identity, credential.contains(":") ? base64().encode(credential.getBytes()) : credential);
this.authType = authType;
}

/**
* Return the base64 encoded value of the credential.
*
* @return the base 64 encoded authentication value.
*/
@Nullable
public String authValue() {
return this.credential;
}

/**
* Return the authentication type.
*
* @return the authentication type.
*/
public AuthenticationType authType() {
return authType;
}
Expand All @@ -60,42 +72,70 @@ public static Builder builder() {

public static class Builder {

private String authValue;
private AuthenticationType authType;
private String identity = "anonymous";
private String credential = identity + ":";
private AuthenticationType authType = AuthenticationType.Anonymous;

/**
* Set 'Basic' credentials.
*
* @param basicCredentials value to use for 'Basic' credentials.
* Set 'UsernamePassword' credentials.
*
* @param usernamePassword value to use for 'UsernamePassword' credentials. It can be the {@code username:password} in clear text or its base64 encoded value.
* @return this Builder.
*/
public Builder credentials(final String basicCredentials) {
this.authValue = Objects.requireNonNull(basicCredentials);
this.authType = AuthenticationType.Basic;
public Builder credentials(final String usernamePassword) {
this.identity = Objects.requireNonNull(extractIdentity(usernamePassword));
this.credential = Objects.requireNonNull(usernamePassword);
this.authType = AuthenticationType.UsernamePassword;
return this;
}

/**
* Set 'Bearer' credentials.
*
* @param tokenCredentials value to use for 'Bearer' credentials.
* Set 'UsernameApiToken' credentials.
*
* @param apiTokenCredentials value to use for 'ApiToken' credentials. It can be the {@code username:apiToken} in clear text or its base64 encoded value.
* @return this Builder.
*/
public Builder token(final String tokenCredentials) {
this.authValue = Objects.requireNonNull(tokenCredentials);
this.authType = AuthenticationType.Bearer;
public Builder apiToken(final String apiTokenCredentials) {
this.identity = Objects.requireNonNull(extractIdentity(apiTokenCredentials));
this.credential = Objects.requireNonNull(apiTokenCredentials);
this.authType = AuthenticationType.UsernameApiToken;
return this;
}

/**
* Extract the identity from the credential.
*
* The credential is entered by the user in one of two forms:
* <ol>
* <li>Colon separated form: <code>username:password</code> or <code>username:password</code>
* <li>Base64 encoded of the colon separated form.
* </ol>
* Either way the identity is the username, and it can be extracted directly or by decoding.
*/
private String extractIdentity(final String credentialString) {
String maybeEncoded = credentialString;
String decoded;
if (!maybeEncoded.contains(":")) {
decoded = new String(base64().decode(credentialString),StandardCharsets.UTF_8);
} else {
decoded = credentialString;
}
if (!decoded.contains(":")) {
throw new UndetectableIdentityException("Unable to detect the identity being used in '" + credentialString + "'. Supported types are a user:password, or a user:apiToken, or their base64 encoded value.");
}
if (decoded.equals(":")) {
return "";
}
return decoded.split(":")[0];
}

/**
* Build and instance of JenkinsCredentials.
*
*
* @return instance of JenkinsCredentials.
*/
public JenkinsAuthentication build() {
return new JenkinsAuthentication(authValue, authType != null
? authType
: AuthenticationType.Anonymous);
return new JenkinsAuthentication(identity, credential, authType);
}
}
}
9 changes: 5 additions & 4 deletions src/main/java/com/cdancy/jenkins/rest/JenkinsClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,15 @@ public Builder credentials(final String optionallyBase64EncodedCredentials) {
}

/**
* Optional token to use for authentication.
* Optional Api token to use for authentication.
* This is not a Bearer token, hence the name apiToken.
*
* @param token authentication token.
* @param apiToken authentication token.
* @return this Builder.
*/
public Builder token(final String token) {
public Builder apiToken(final String apiToken) {
authBuilder = JenkinsAuthentication.builder()
.token(token);
.apiToken(apiToken);
return this;
}

Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/cdancy/jenkins/rest/JenkinsConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public class JenkinsConstants {
public static final String CREDENTIALS_SYSTEM_PROPERTY = "jenkins.rest.credentials";
public static final String CREDENTIALS_ENVIRONMENT_VARIABLE = CREDENTIALS_SYSTEM_PROPERTY.replaceAll("\\.", "_").toUpperCase();

public static final String TOKEN_SYSTEM_PROPERTY = "jenkins.rest.token";
public static final String TOKEN_ENVIRONMENT_VARIABLE = TOKEN_SYSTEM_PROPERTY.replaceAll("\\.", "_").toUpperCase();
public static final String API_TOKEN_SYSTEM_PROPERTY = "jenkins.rest.api.token";
public static final String API_TOKEN_ENVIRONMENT_VARIABLE = API_TOKEN_SYSTEM_PROPERTY.replaceAll("\\.", "_").toUpperCase();

public static final String DEFAULT_ENDPOINT = "http://127.0.0.1:7990";

Expand All @@ -41,6 +41,8 @@ public class JenkinsConstants {

public static final String OPTIONAL_FOLDER_PATH_PARAM = "optionalFolderPath";

public static final String USER_IN_USER_API = "user";

public static final String JENKINS_COOKIES_JSESSIONID = "JSESSIONID";

protected JenkinsConstants() {
Expand Down
Loading

0 comments on commit 7bed241

Please sign in to comment.