Compare commits

...

4 Commits

Author SHA1 Message Date
Vivek Pandey 07bced4a2d Curl wrapper to call APIs with JWT token 2016-08-05 17:52:16 -07:00
Vivek Pandey 24e0b1d46b Fix for failure during permission check
Added missing GrantedAuthorities to JwtAuthenticationToken.
2016-08-05 17:50:42 -07:00
Vivek Pandey 225ab9a854 TOC update 2016-08-04 16:35:08 -07:00
Vivek Pandey ab78663c9c JENKINS-35783# JWT support 2016-08-04 16:06:20 -07:00
24 changed files with 1137 additions and 55 deletions

83
bin/jwtcurl.sh Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env bash
##
#
# Usage
#
# jwtcurl [-u username:password] [-b BASE_URL] "[-X GET|POST|PUT|DELETE] BO_API_URL"
#
# Options:
# -v: verbose output
# -u: basic auth parameter in username:password format
# -b: base url of jenkins without trailing slash. e.g. http://localhost:8080/jenkins or https://blueocean.io
#
# Note: You need to enclose last argument in double quotes if you are passing arguments to curl.
#
# Examples:
#
# Anonymous user:
#
# jwtcurl http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/p1/
#
# User with credentials:
#
# jwtcurl -u admin:admin http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/p1/
#
# Use base url other than http://localhost:8080/jenkins
#
# jwtcurl -u admin:admin -b https://myjenkinshost http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/p1/
#
# Author: Vivek Pandey
#
##
if [ $# -eq 0 ]
then
echo "Usage: jwtcurl [-v] [-u username:password] [-b BASE_URL] \"-X [GET|POST|PUT|DELETE] BO_API_URL\""
exit 1;
fi
while [[ $# -gt 1 ]]
do
key="$1"
case $key in
-u)
CREDENTIAL="-u $2"
shift
;;
-b)
BASE_URL="$2"
shift
;;
-v)
VERBOSE="$2"
shift
;;
*)
# unknown option
;;
esac
shift
done
if [ ! -z "$VERBOSE" ]; then
SETX="set -x"
CURL_VERBOSE="-v"
fi
if [ -z "${BASE_URL}" ]; then
BASE_URL=http://localhost:8080/jenkins
fi
${SETX}
TOKEN=$(curl ${CURL_VERBOSE} -s -X GET ${CREDENTIAL} -I ${BASE_URL}/jwt-auth/token | awk 'BEGIN {FS=": "}/^X-BLUEOCEAN-JWT/{print $2}'|sed $'s/\r//')
if [ -z "${TOKEN}" ]; then
echo "Failed to get JWT token"
echo $?
exit 1
fi
curl ${CURL_VERBOSE} -H "Authorization: Bearer ${TOKEN}" $@

View File

@ -1,6 +1,7 @@
package io.jenkins.blueocean.analyticstools;
import hudson.Extension;
import hudson.Plugin;
import io.jenkins.blueocean.BluePageDecorator;
import jenkins.model.Jenkins;
@ -17,6 +18,7 @@ public class AnalyticsTools extends BluePageDecorator {
/** gives Blueocean plugin version. blueocean-web being core module is looked at to determine the version */
public String getBlueOceanPluginVersion(){
return Jenkins.getInstance().getPlugin("blueocean-web").getWrapper().getVersion();
Plugin plugin = Jenkins.getInstance().getPlugin("blueocean-web");
return (plugin != null) ? plugin.getWrapper().getVersion() : null;
}
}

21
blueocean-jwt/LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2016 CloudBees Inc and a number of other of contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

88
blueocean-jwt/README.md Normal file
View File

@ -0,0 +1,88 @@
# BlueOcean JWT Plugin
This plugin provides JWT authenticated related APIs. JWT token is signed using RSA256 algorithm. This is asymmetric
algorithm, this means the token is signed using the private key and Client must use corresponding public key to verify
the claims.
# APIs
## JWT Token API
JWT token is generated for the user in session. In Jenkins there is always a user in context, that is if there is no
logged in user then the generated token will carry the claim for anonymous user.
Default expiry time of token is 30 minutes.
JWT token is return as X-BLUEOCEAN-JWT HTTP header.
GET /jwt-auth/token
HTTP/1.1 200 OK
X-BLUEOCEAN-JWT: eyJraWQiOiI2M2ZhMTY0ZWRhMDk0NjNjOGZlZTI2Njg4ZjgxOTZmZCIsImFsZyI6IlJTMjU2IiwidHlwIjoiSldUIn0.eyJqdGkiOiJiMGVmMjJiNDliNWM0N2JjODU4YTg2MDdkM2Y0NGQzMyIsImlzcyI6ImJsdWVvY2Vhbi1qd3Q6Iiwic3ViIjoiYWxpY2UiLCJuYW1lIjoiQWxpY2UgQ29vcGVyIiwiaWF0IjoxNDcwMzMxNjA1LCJleHAiOjE0NzAzMzM0MDUsIm5iZiI6MTQ3MDMzMTU3NSwiY29udGV4dCI6eyJ1c2VyIjp7ImlkIjoiYWxpY2UiLCJmdWxsTmFtZSI6IkFsaWNlIENvb3BlciIsImVtYWlsIjoiYWxpY2VAamVua2lucy1jaS5vcmcifX19.H1iZAR2ajMeWRhh1VDdbqOtD7Wo0e0FZx8JDDNzphLu2DaLlxVRzBbhZ5TllvPx787kbNeK2tymFu_2Y_59qkq7YxZkrJctZTeiHVlTlHIxf2woBBggkIgoSvzNSsCcX73vjH5A5e54T5e8rUjF56XP05d5-WDvvheLo_Sqn4j19_lXkogCC2-JhDfc7sb8Xnw5PwYNZs29JYSSLOuUWm8UnD3AnBeFBhPfY2bR8-BjPXxdRWAyrZ-bz1CITfOm1xHZ-8NCGsfsUUGlcB_ijPVBt5T_29JWWFnougM1qZ_CEO56xu1572LMUmBYi8ynl75frzoSL_PvZYMXF47zcdg
JSON presentation of this token:
Header:
{"kid":"63fa164eda09463c8fee26688f8196fd","alg":"RS256","typ":"JWT"}
Claims:
{
"name" : "Alice Cooper",
"iss" : "blueocean-jwt:",
"sub" : "alice",
"exp" : 1470333405,
"nbf" : 1470331575,
"context" : {
"user" : {
"id" : "alice",
"fullName" : "Alice Cooper",
"email" : "alice@jenkins-ci.org"
}
},
"jti" : "b0ef22b49b5c47bc858a8607d3f44d33",
"iat" : 1470331605
}
### Change expiry time
JWT tokens expires after 30 minutes (Default). exp claim header gives the time at which token expires. It is unix time
in seconds. Default 30 minutes can be changed by sending expiryTimeInMins query parameter. This parameter value must be
less than maximum expiry time allowed (8 hours or 480 minutes).
This parameter must be used carefully, it has security implications.
GET /jwt-auth/token?expiryTimeInMins=15
## Change maximum allowed expiry time
Use query maxExpiryTimeInMins to change default 8 hours maximum allowed expiry time.
This parameter must be used carefully, it has security implications.
GET /jwt-auth/token?maxExpiryTimeInMins=15
## Json web key (jwk) API
Client can call this API to get public key using the key id received as part of JWT header field 'kid'. This public key
must be used to verify the JWT token.
GET /jwt-auth/jwks/bab71d7b184548a6b93480721d352ba1
HTTP/1.1 200 OK
Content-type: application/json
{
"alg" : "RS256",
"e" : "AQAB",
"kty" : "RSA",
"n" : "AMmWNNrmWzJXik7K7gmDkPumxqPzxc/JnxWsZ3CrhJGSO8hIgfsN6M5UHWSwkAoBHyNIaaPXhubWpcWCRewiI0U2Aw4jO3vzxNndRB9YaDPrrWDjvKBaqMC08IePPxmxXCj3ZS0QoEpf6rczdm2f9Of6Fro0TufXf2EYjLndBH7ep6iDQ4/TG7FkD7o39/GXuHAin0sz7atrPun3tlkuxllu5XNV+yW6WusrNIz3txyvKKEyQX950eW/6mMD0hS6yT7TbAwfrxkTnq4SiagCTllV+ct4wfnONDrao3WYgZnNgohsX/nEnYMHYq592n2WZW/i2+PNaFZlL2+3QgWO4qc=",
"use" : "sig",
"key_ops" : [
"verify"
],
"kid" : "bab71d7b184548a6b93480721d352ba1"
}

34
blueocean-jwt/pom.xml Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.jenkins.blueocean</groupId>
<artifactId>blueocean-parent</artifactId>
<version>1.0-alpha-6-SNAPSHOT</version>
</parent>
<artifactId>blueocean-jwt</artifactId>
<packaging>hpi</packaging>
<name>BlueOcean :: JWT module</name>
<url>https://wiki.jenkins-ci.org/display/JENKINS/Blue+Ocean+Plugin</url>
<dependencies>
<dependency>
<groupId>org.bitbucket.b_c</groupId>
<artifactId>jose4j</artifactId>
<version>0.5.1</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>blueocean-commons</artifactId>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>mailer</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,21 @@
package io.jenkins.blueocean.auth.jwt;
import net.sf.json.JSONObject;
import org.kohsuke.stapler.WebMethod;
/**
* Issuer of JSON Web Key.
*
* @author Kohsuke Kawaguchi
* @author Vivek Pandey
* @see JwtAuthenticationService#getJwks(String)
*/
public abstract class JwkService {
/**
*
* @return Gives JWK JSONObject
*/
@WebMethod(name = "")
public abstract JSONObject getJwk();
}

View File

@ -0,0 +1,50 @@
package io.jenkins.blueocean.auth.jwt;
import hudson.ExtensionPoint;
import hudson.model.RootAction;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.WebMethod;
import org.kohsuke.stapler.verb.GET;
import javax.annotation.Nullable;
/**
* JWT endpoint resource. Provides functionality to get JWT token and also provides JWK endpoint to get
* public key using keyId.
*
* @author Vivek Pandey
*/
public abstract class JwtAuthenticationService implements RootAction, ExtensionPoint{
@Override
public String getUrlName() {
return "jwt-auth";
}
/**
* Gives JWT token for authenticated user. See https://tools.ietf.org/html/rfc7519.
*
* @param expiryTimeInMins token expiry time. Default 30 min.
* @param maxExpiryTimeInMins max token expiry time. Default expiry time is 8 hours (480 mins)
*
* @return JWT if there is authenticated user or if anonymous user has at least READ permission, otherwise 401
* error code is returned
*
* @see JwtToken
*/
@GET
@WebMethod(name = "token")
public abstract JwtToken getToken(@Nullable @QueryParameter("expiryTimeInMins") Integer expiryTimeInMins,
@Nullable @QueryParameter("maxExpiryTimeInMins") Integer maxExpiryTimeInMins);
/**
* Gives Json web key. See https://tools.ietf.org/html/rfc7517
*
* @param keyId keyId of the key
*
* @return JWK reponse
*/
@GET
public abstract JwkService getJwks(String keyId);
}

View File

@ -0,0 +1,107 @@
package io.jenkins.blueocean.auth.jwt;
import io.jenkins.blueocean.commons.ServiceException;
import jenkins.security.RSADigitalSignatureConfidentialKey;
import net.sf.json.JSONObject;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwx.HeaderParameterNames;
import org.jose4j.lang.JoseException;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.UUID;
/**
* JWT token
*
* Generates JWT token
*
* @author Vivek Pandey
*/
public final class JwtToken implements HttpResponse{
private static final Logger logger = LoggerFactory.getLogger(JwtToken.class);
public static final String X_BLUEOCEAN_JWT="X-BLUEOCEAN-JWT";
private static final String DEFAULT_KEY_ID = UUID.randomUUID().toString().replace("-", "");
/**
* JWT header
*/
public final JSONObject header = new JSONObject();
/**
* JWT Claim
*/
public final JSONObject claim = new JSONObject();
/**
* Generates base64 representation of JWT token sign using "RS256" algorithm
*
* getHeader().toBase64UrlEncode() + "." + getClaim().toBase64UrlEncode() + "." + sign
*
*
* @return base64 representation of JWT token
*/
public String sign(){
for(JwtTokenDecorator decorator: JwtTokenDecorator.all()){
decorator.decorate(this);
}
/**
* kid might have been set already by using {@link #header} or {@link JwtTokenDecorator}, if present use it
* otherwise use the default kid
*/
String keyId = (String)header.get(HeaderParameterNames.KEY_ID);
if(keyId == null){
keyId = DEFAULT_KEY_ID;
}
JwtRsaDigitalSignatureKey rsaDigitalSignatureConfidentialKey = new JwtRsaDigitalSignatureKey(keyId);
try {
return rsaDigitalSignatureConfidentialKey.sign(claim);
} catch (JoseException e) {
String msg = "Failed to sign JWT token: "+e.getMessage();
logger.error(msg);
throw new ServiceException.UnexpectedErrorException(msg, e);
}
}
@Override
public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException {
rsp.setStatus(200);
rsp.addHeader(X_BLUEOCEAN_JWT, sign());
}
public final static class JwtRsaDigitalSignatureKey extends RSADigitalSignatureConfidentialKey{
private final String id;
public JwtRsaDigitalSignatureKey(String id) {
super("blueoceanJwt-"+id);
this.id = id;
}
public String sign(JSONObject claim) throws JoseException {
JsonWebSignature jsonWebSignature = new JsonWebSignature();
jsonWebSignature.setPayload(claim.toString());
jsonWebSignature.setKey(getPrivateKey());
jsonWebSignature.setKeyIdHeaderValue(id);
jsonWebSignature.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
jsonWebSignature.setHeader(HeaderParameterNames.TYPE, "JWT");
return jsonWebSignature.getCompactSerialization();
}
public boolean exists() throws IOException {
return super.load()!=null;
}
}
}

View File

@ -0,0 +1,28 @@
package io.jenkins.blueocean.auth.jwt;
import hudson.ExtensionList;
import hudson.ExtensionPoint;
/**
* Participates in the creation of JwtToken
*
* @author Vivek Pandey
*/
public abstract class JwtTokenDecorator implements ExtensionPoint {
/** Decorates {@link JwtToken}
*
* @param token token to be decorated
*
* @return returns decorated token
*/
public abstract JwtToken decorate(JwtToken token);
/**
* Returns all the registered {@link JwtTokenDecorator}s
*/
public static ExtensionList<JwtTokenDecorator> all() {
return ExtensionList.lookup(JwtTokenDecorator.class);
}
}

View File

@ -0,0 +1,151 @@
package io.jenkins.blueocean.auth.jwt.impl;
import com.google.common.collect.ImmutableList;
import hudson.Extension;
import hudson.Plugin;
import hudson.model.User;
import hudson.remoting.Base64;
import hudson.tasks.Mailer;
import io.jenkins.blueocean.auth.jwt.JwkService;
import io.jenkins.blueocean.auth.jwt.JwtAuthenticationService;
import io.jenkins.blueocean.auth.jwt.JwtToken;
import io.jenkins.blueocean.commons.ServiceException;
import jenkins.model.Jenkins;
import net.sf.json.JSONObject;
import org.acegisecurity.Authentication;
import org.kohsuke.stapler.QueryParameter;
import javax.annotation.Nullable;
import java.io.IOException;
import java.security.interfaces.RSAPublicKey;
import java.util.Collections;
import java.util.UUID;
/**
* @author Vivek Pandey
*/
@Extension
public class JwtImpl extends JwtAuthenticationService {
private static int DEFAULT_EXPIRY_IN_SEC = 1800;
private static int DEFAULT_MAX_EXPIRY_TIME_IN_MIN = 480;
private static int DEFAULT_NOT_BEFORE_IN_SEC = 30;
@Override
public JwtToken getToken(@Nullable @QueryParameter("expiryTimeInMins") Integer expiryTimeInMins, @Nullable @QueryParameter("maxExpiryTimeInMins") Integer maxExpiryTimeInMins) {
String t = System.getProperty("EXPIRY_TIME_IN_MINS");
long expiryTime=DEFAULT_EXPIRY_IN_SEC;
if(t!= null){
expiryTime = Integer.parseInt(t);
}
int maxExpiryTime = DEFAULT_MAX_EXPIRY_TIME_IN_MIN;
t = System.getProperty("MAX_EXPIRY_TIME_IN_MINS");
if(t!= null){
maxExpiryTime = Integer.parseInt(t);
}
if(maxExpiryTimeInMins != null){
maxExpiryTime = maxExpiryTimeInMins;
}
if(expiryTimeInMins != null){
if(expiryTimeInMins > maxExpiryTime) {
throw new ServiceException.BadRequestExpception(
String.format("expiryTimeInMins %s can't be greated than %s", expiryTimeInMins, maxExpiryTime));
}
expiryTime = expiryTimeInMins * 60;
}
Authentication authentication = Jenkins.getInstance().getAuthentication();
if(authentication == null){
throw new ServiceException.UnauthorizedException("Unauthorized: No login session found");
}
String userId = authentication.getName();
User user = User.get(userId, false, Collections.emptyMap());
String email = null;
String fullName = null;
if(user != null) {
fullName = user.getFullName();
userId = user.getId();
Mailer.UserProperty p = user.getProperty(Mailer.UserProperty.class);
if(p!=null)
email = p.getAddress();
}
Plugin plugin = Jenkins.getInstance().getPlugin("blueocean-jwt");
String issuer = "blueocean-jwt:"+ ((plugin!=null) ? plugin.getWrapper().getVersion() : "");
JwtToken jwtToken = new JwtToken();
jwtToken.claim.put("jti", UUID.randomUUID().toString().replace("-",""));
jwtToken.claim.put("iss", issuer);
jwtToken.claim.put("sub", userId);
jwtToken.claim.put("name", fullName);
long currentTime = System.currentTimeMillis()/1000;
jwtToken.claim.put("iat", currentTime);
jwtToken.claim.put("exp", currentTime+expiryTime);
jwtToken.claim.put("nbf", currentTime - DEFAULT_NOT_BEFORE_IN_SEC);
//set claim
JSONObject context = new JSONObject();
JSONObject userObject = new JSONObject();
userObject.put("id", userId);
userObject.put("fullName", fullName);
userObject.put("email", email);
context.put("user", userObject);
jwtToken.claim.put("context", context);
return jwtToken;
}
public JwkFactory getJwks(String name) {
if(name == null){
throw new ServiceException.BadRequestExpception("keyId is required");
}
return new JwkFactory(name);
}
@Override
public String getIconFileName() {
return null;
}
@Override
public String getDisplayName() {
return "BlueOcean Jwt endpoint";
}
public class JwkFactory extends JwkService {
private final String keyId;
public JwkFactory(String keyId) {
this.keyId = keyId;
}
@Override
public JSONObject getJwk() {
JwtToken.JwtRsaDigitalSignatureKey key = new JwtToken.JwtRsaDigitalSignatureKey(keyId);
try {
if(!key.exists()){
throw new ServiceException.NotFoundException(String.format("kid %s not found", keyId));
}
} catch (IOException e) {
throw new ServiceException.UnexpectedErrorException("Unexpected error: "+e.getMessage(), e);
}
RSAPublicKey publicKey = key.getPublicKey();
JSONObject jwk = new JSONObject();
jwk.put("kty", "RSA");
jwk.put("alg","RS256");
jwk.put("kid",keyId);
jwk.put("use", "sig");
jwk.put("key_ops", ImmutableList.of("verify"));
jwk.put("n", Base64.encode(publicKey.getModulus().toByteArray()));
jwk.put("e", Base64.encode(publicKey.getPublicExponent().toByteArray()));
return jwk;
}
}
}

View File

@ -0,0 +1,4 @@
<?jelly escape-by-default='true'?>
<div>
BlueOcean JWT plugin: Enables JWT based BlueOcean API authentication
</div>

View File

@ -0,0 +1,135 @@
package io.jenkins.blueocean.auth.jwt.impl;
import com.gargoylesoftware.htmlunit.Page;
import hudson.model.User;
import hudson.tasks.Mailer;
import net.sf.json.JSONObject;
import org.jose4j.jwk.RsaJsonWebKey;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.jwx.JsonWebStructure;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import java.util.Map;
/**
* @author Vivek Pandey
*/
public class JwtImplTest {
@Rule
public JenkinsRule j = new JenkinsRule();
@Test
public void getToken() throws Exception {
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
User user = j.jenkins.getUser("alice");
user.setFullName("Alice Cooper");
user.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
JenkinsRule.WebClient webClient = j.createWebClient();
webClient.login("alice");
Page page = webClient.goTo("jwt-auth/token/", null);
String token = page.getWebResponse().getResponseHeaderValue("X-BLUEOCEAN-JWT");
Assert.assertNotNull(token);
JsonWebStructure jsonWebStructure = JsonWebStructure.fromCompactSerialization(token);
Assert.assertTrue(jsonWebStructure instanceof JsonWebSignature);
JsonWebSignature jsw = (JsonWebSignature) jsonWebStructure;
System.out.println(token);
System.out.println(jsw.toString());
String kid = jsw.getHeader("kid");
Assert.assertNotNull(kid);
page = webClient.goTo("jwt-auth/jwks/"+kid+"/", "application/json");
// for(NameValuePair valuePair: page.getWebResponse().getResponseHeaders()){
// System.out.println(valuePair);
// }
JSONObject jsonObject = JSONObject.fromObject(page.getWebResponse().getContentAsString());
System.out.println(jsonObject.toString());
RsaJsonWebKey rsaJsonWebKey = new RsaJsonWebKey(jsonObject,null);
JwtConsumer jwtConsumer = new JwtConsumerBuilder()
.setRequireExpirationTime() // the JWT must have an expiration time
.setAllowedClockSkewInSeconds(30) // allow some leeway in validating time based claims to account for clock skew
.setRequireSubject() // the JWT must have a subject claim
.setVerificationKey(rsaJsonWebKey.getKey()) // verify the sign with the public key
.build(); // create the JwtConsumer instance
JwtClaims claims = jwtConsumer.processToClaims(token);
Assert.assertEquals("alice",claims.getSubject());
Map<String,Object> claimMap = claims.getClaimsMap();
Map<String,Object> context = (Map<String, Object>) claimMap.get("context");
Map<String,String> userContext = (Map<String, String>) context.get("user");
Assert.assertEquals("alice", userContext.get("id"));
Assert.assertEquals("Alice Cooper", userContext.get("fullName"));
Assert.assertEquals("alice@jenkins-ci.org", userContext.get("email"));
}
@Test
public void anonymousUserToken() throws Exception{
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
JenkinsRule.WebClient webClient = j.createWebClient();
Page page = webClient.goTo("jwt-auth/token/", null);
String token = page.getWebResponse().getResponseHeaderValue("X-BLUEOCEAN-JWT");
Assert.assertNotNull(token);
JsonWebStructure jsonWebStructure = JsonWebStructure.fromCompactSerialization(token);
Assert.assertTrue(jsonWebStructure instanceof JsonWebSignature);
JsonWebSignature jsw = (JsonWebSignature) jsonWebStructure;
String kid = jsw.getHeader("kid");
Assert.assertNotNull(kid);
page = webClient.goTo("jwt-auth/jwks/"+kid+"/", "application/json");
// for(NameValuePair valuePair: page.getWebResponse().getResponseHeaders()){
// System.out.println(valuePair);
// }
JSONObject jsonObject = JSONObject.fromObject(page.getWebResponse().getContentAsString());
RsaJsonWebKey rsaJsonWebKey = new RsaJsonWebKey(jsonObject,null);
JwtConsumer jwtConsumer = new JwtConsumerBuilder()
.setRequireExpirationTime() // the JWT must have an expiration time
.setAllowedClockSkewInSeconds(30) // allow some leeway in validating time based claims to account for clock skew
.setRequireSubject() // the JWT must have a subject claim
.setVerificationKey(rsaJsonWebKey.getKey()) // verify the sign with the public key
.build(); // create the JwtConsumer instance
JwtClaims claims = jwtConsumer.processToClaims(token);
Assert.assertEquals("anonymous",claims.getSubject());
Map<String,Object> claimMap = claims.getClaimsMap();
Map<String,Object> context = (Map<String, Object>) claimMap.get("context");
Map<String,String> userContext = (Map<String, String>) context.get("user");
Assert.assertEquals("anonymous", userContext.get("id"));
}
}

View File

@ -429,9 +429,10 @@ public class MultiBranchTest extends PipelineBaseTest {
WorkflowJob p = scheduleAndFindBranchProject(mp, "master");
j.waitUntilNoActivity();
String token = getJwtToken(j.jenkins, "alice", "alice");
Map m = new RequestBuilder(baseUrl)
.put("/organizations/jenkins/pipelines/p/favorite")
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", true))
.build(Map.class);
@ -443,7 +444,7 @@ public class MultiBranchTest extends PipelineBaseTest {
List l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(1,l.size());
@ -460,7 +461,7 @@ public class MultiBranchTest extends PipelineBaseTest {
m = new RequestBuilder(baseUrl)
.put(getUrlFromHref(ref))
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", false))
.build(Map.class);
@ -472,7 +473,7 @@ public class MultiBranchTest extends PipelineBaseTest {
l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(0,l.size());
@ -480,7 +481,7 @@ public class MultiBranchTest extends PipelineBaseTest {
new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("bob","bob")
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
.status(403)
.build(String.class);
}
@ -503,9 +504,10 @@ public class MultiBranchTest extends PipelineBaseTest {
WorkflowJob p1 = scheduleAndFindBranchProject(mp, "feature2");
String token = getJwtToken(j.jenkins,"alice","alice");
Map map = new RequestBuilder(baseUrl)
.put("/organizations/jenkins/pipelines/p/branches/feature2/favorite")
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", true))
.build(Map.class);
@ -516,7 +518,7 @@ public class MultiBranchTest extends PipelineBaseTest {
List l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(1, l.size());
@ -533,7 +535,7 @@ public class MultiBranchTest extends PipelineBaseTest {
map = new RequestBuilder(baseUrl)
.put(getUrlFromHref(getHrefFromLinks((Map)l.get(0), "self")))
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", false))
.build(Map.class);
@ -544,7 +546,7 @@ public class MultiBranchTest extends PipelineBaseTest {
l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(0, l.size());
@ -552,7 +554,7 @@ public class MultiBranchTest extends PipelineBaseTest {
new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("bob","bob")
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
.status(403)
.build(String.class);
@ -582,9 +584,11 @@ public class MultiBranchTest extends PipelineBaseTest {
fup.toggleFavorite(mp.getFullName());
user.save();
String token = getJwtToken(j.jenkins,"alice", "alice");
List l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(1, l.size());
@ -603,7 +607,7 @@ public class MultiBranchTest extends PipelineBaseTest {
Map m = new RequestBuilder(baseUrl)
.put(getUrlFromHref(getUrlFromHref(href)))
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", false))
.build(Map.class);
@ -614,7 +618,7 @@ public class MultiBranchTest extends PipelineBaseTest {
l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(0,l.size());

View File

@ -6,6 +6,7 @@ import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.ObjectMapper;
import com.mashape.unirest.http.Unirest;
import com.mashape.unirest.http.exceptions.UnirestException;
import com.mashape.unirest.request.GetRequest;
import com.mashape.unirest.request.HttpRequest;
import com.mashape.unirest.request.HttpRequestWithBody;
import hudson.Util;
@ -13,6 +14,7 @@ import hudson.model.Job;
import hudson.model.Run;
import io.jenkins.blueocean.commons.JsonConverter;
import jenkins.branch.MultiBranchProject;
import jenkins.model.Jenkins;
import org.jenkinsci.plugins.workflow.actions.ThreadNameAction;
import org.jenkinsci.plugins.workflow.graph.FlowNode;
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
@ -34,6 +36,8 @@ import java.util.List;
import java.util.Map;
import java.util.logging.LogManager;
import static io.jenkins.blueocean.auth.jwt.JwtToken.X_BLUEOCEAN_JWT;
/**
* @author Vivek Pandey
*/
@ -45,6 +49,8 @@ public abstract class PipelineBaseTest{
protected String baseUrl;
protected String jwtToken;
protected String getContextPath(){
return "blue/rest";
}
@ -56,6 +62,7 @@ public abstract class PipelineBaseTest{
LogManager.getLogManager().readConfiguration(is);
}
this.baseUrl = j.jenkins.getRootUrl() + getContextPath();
this.jwtToken = getJwtToken(j.jenkins);
Unirest.setObjectMapper(new ObjectMapper() {
public <T> T readValue(String value, Class<T> valueType) {
try {
@ -109,12 +116,14 @@ public abstract class PipelineBaseTest{
if(HttpResponse.class.isAssignableFrom(type)){
HttpResponse<String> response = Unirest.get(getBaseUrl(path)).header("Accept", accept)
.header("Accept-Encoding","")
.header("Authorization", "Bearer "+jwtToken)
.asString();
Assert.assertEquals(expectedStatus, response.getStatus());
return (T) response;
}
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept).asObject(type);
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept)
.header("Authorization", "Bearer "+jwtToken).asObject(type);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
} catch (UnirestException e) {
@ -145,6 +154,7 @@ public abstract class PipelineBaseTest{
try {
HttpResponse<Map> response = Unirest.post(getBaseUrl(path))
.header("Content-Type","application/json")
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(Map.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -159,6 +169,7 @@ public abstract class PipelineBaseTest{
HttpResponse<String> response = Unirest.post(getBaseUrl(path))
.header("Content-Type",contentType)
.header("Accept-Encoding","")
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(String.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -179,6 +190,7 @@ public abstract class PipelineBaseTest{
HttpResponse<Map> response = Unirest.put(getBaseUrl(path))
.header("Content-Type","application/json")
.header("Accept","application/json")
.header("Authorization", "Bearer "+jwtToken)
//Unirest by default sets accept-encoding to gzip but stapler is sending malformed gzip value if
// the response length is small (in this case its 20 chars).
// Needs investigation in stapler to see whats going on there.
@ -198,6 +210,7 @@ public abstract class PipelineBaseTest{
try {
HttpResponse<String> response = Unirest.put(getBaseUrl(path))
.header("Content-Type",contentType)
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(String.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -215,6 +228,7 @@ public abstract class PipelineBaseTest{
try {
HttpResponse<Map> response = Unirest.patch(getBaseUrl(path))
.header("Content-Type","application/json")
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(Map.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -229,6 +243,7 @@ public abstract class PipelineBaseTest{
try {
HttpResponse<String> response = Unirest.patch(getBaseUrl(path))
.header("Content-Type",contentType)
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(String.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -353,7 +368,7 @@ public abstract class PipelineBaseTest{
public RequestBuilder request() {
return new RequestBuilder(baseUrl);
}
public static class RequestBuilder {
public class RequestBuilder {
private String url;
private String username;
private String method;
@ -362,6 +377,7 @@ public abstract class PipelineBaseTest{
private String contentType = "application/json";
private String baseUrl;
private int expectedStatus = 200;
private String token;
private String getBaseUrl(String path){
@ -394,6 +410,10 @@ public abstract class PipelineBaseTest{
return this;
}
public RequestBuilder jwtToken(String token){
this.token = token;
return this;
}
public RequestBuilder data(Map data) {
this.data = data;
@ -454,6 +474,11 @@ public abstract class PipelineBaseTest{
}
request.header("Accept-Encoding","");
if(token == null) {
request.header("Authorization", "Bearer " + PipelineBaseTest.this.jwtToken);
}else{
request.header("Authorization", "Bearer " + token);
}
request.header("Content-Type", contentType);
if(!Strings.isNullOrEmpty(username) && !Strings.isNullOrEmpty(password)){
@ -471,4 +496,30 @@ public abstract class PipelineBaseTest{
}
}
}
public static String getJwtToken(Jenkins jenkins) throws UnirestException {
HttpResponse<String> response = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
.header("Accept-Encoding","").asString();
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
Assert.assertNotNull(token);
//we do not validate it for test optimization and for the fact that there are separate
// tests that test token generation and validation
return token;
}
public static String getJwtToken(Jenkins jenkins, String username, String password) throws UnirestException {
GetRequest request = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
.header("Accept-Encoding","");
if(username!= null && password!= null){
request.basicAuth(username,password);
}
HttpResponse<String> response = request.asString();
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
Assert.assertNotNull(token);
//we do not validate it for test optimization and for the fact that there are separate
// tests that test token generation and validation
return token;
}
}

View File

@ -35,6 +35,10 @@
<groupId>${project.groupId}</groupId>
<artifactId>blueocean-commons</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>blueocean-jwt</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>blueocean-rest-impl</artifactId>
@ -107,12 +111,14 @@
//
linkHPI('blueocean-web');
linkHPI('blueocean-dashboard');
linkHPI('blueocean-personalization')
linkHPI('blueocean-personalization');
linkHPI('blueocean-rest');
linkHPI('blueocean-commons');
linkHPI('blueocean-jwt');
linkHPI('blueocean-rest-impl');
linkHPI('blueocean-pipeline-api-impl')
linkHPI('blueocean-analytics-tools')
linkHPI('blueocean-pipeline-api-impl');
linkHPI('blueocean-analytics-tools');
</source>
</configuration>
</plugin>

View File

@ -28,6 +28,10 @@
<artifactId>scm-api</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>blueocean-jwt</artifactId>
</dependency>
<!-- Test plugins -->
<dependency>

View File

@ -4,17 +4,23 @@ import com.google.inject.Binder;
import com.google.inject.Inject;
import com.google.inject.Module;
import hudson.Extension;
import hudson.model.RootAction;
import hudson.model.UnprotectedRootAction;
import io.jenkins.blueocean.BlueOceanUI;
import org.acegisecurity.context.SecurityContext;
import org.acegisecurity.context.SecurityContextHolder;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerProxy;
import org.kohsuke.stapler.StaplerRequest;
/**
* @author Kohsuke Kawaguchi
*/
@Extension
public class BlueOceanRootAction implements RootAction, StaplerProxy {
public class BlueOceanRootAction implements UnprotectedRootAction, StaplerProxy {
private static final String URL_BASE="blue";
private final boolean disableJWT = Boolean.getBoolean("DISABLE_BLUEOCEAN_JWT_AUTHENTICATION");
@Inject
private BlueOceanUI app;
@ -38,6 +44,14 @@ public class BlueOceanRootAction implements RootAction, StaplerProxy {
@Override
public Object getTarget() {
StaplerRequest request = Stapler.getCurrentRequest();
if(!disableJWT && request.getOriginalRestOfPath().startsWith("/rest/")) {
JwtAuthenticationToken tokenAuthentication = new JwtAuthenticationToken(request);
SecurityContext holder = SecurityContextHolder.getContext();
holder.setAuthentication(tokenAuthentication);
}
return app;
}

View File

@ -0,0 +1,135 @@
package io.jenkins.blueocean.service.embedded;
import hudson.model.User;
import io.jenkins.blueocean.auth.jwt.JwtToken;
import io.jenkins.blueocean.commons.ServiceException;
import jenkins.model.Jenkins;
import org.acegisecurity.GrantedAuthority;
import org.acegisecurity.GrantedAuthorityImpl;
import org.acegisecurity.providers.AbstractAuthenticationToken;
import org.acegisecurity.userdetails.UserDetails;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.NumericDate;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwx.JsonWebStructure;
import org.jose4j.lang.JoseException;
import org.kohsuke.stapler.StaplerRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Collections;
import static org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA256;
/**
* @author Kohsuke Kawaguchi
*/
public final class JwtAuthenticationToken extends AbstractAuthenticationToken{
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationToken.class);
private final String name;
private final GrantedAuthority[] grantedAuthorities;
public JwtAuthenticationToken(StaplerRequest request) {
String authHeader = request.getHeader("Authorization");
if(authHeader == null || !authHeader.startsWith("Bearer")){
throw new ServiceException.UnauthorizedException("JWT token not found");
}
String token = authHeader.substring("Bearer ".length());
try {
JsonWebStructure jws = JsonWebStructure.fromCompactSerialization(token);
String alg = jws.getAlgorithmHeaderValue();
if(alg == null || !alg.equals(RSA_USING_SHA256)){
logger.error(String.format("Invalid JWT token: unsupported algorithm in header, found %s, expected %s", alg, RSA_USING_SHA256));
throw new ServiceException.UnauthorizedException("Invalid JWT token");
}
String kid = jws.getKeyIdHeaderValue();
if(kid == null){
logger.error("Invalid JWT token: missing kid");
throw new ServiceException.UnauthorizedException("Invalid JWT token");
}
JwtToken.JwtRsaDigitalSignatureKey key = new JwtToken.JwtRsaDigitalSignatureKey(kid);
try {
if(!key.exists()){
throw new ServiceException.NotFoundException(String.format("kid %s not found", kid));
}
} catch (IOException e) {
logger.error(String.format("Error reading RSA key for id %s: %s",kid,e.getMessage()),e);
throw new ServiceException.UnexpectedErrorException("Unexpected error: "+e.getMessage(), e);
}
JwtConsumer jwtConsumer = new JwtConsumerBuilder()
.setRequireExpirationTime() // the JWT must have an expiration time
.setRequireJwtId()
.setAllowedClockSkewInSeconds(30) // allow some leeway in validating time based claims to account for clock skew
.setRequireSubject() // the JWT must have a subject claim
.setVerificationKey(key.getPublicKey()) // verify the sign with the public key
.build(); // create the JwtConsumer instance
try {
JwtContext context = jwtConsumer.process(token);
JwtClaims claims = context.getJwtClaims();
//check if token expired
NumericDate expirationTime = claims.getExpirationTime();
if (expirationTime.isBefore(NumericDate.now())){
throw new ServiceException.UnauthorizedException("Invalid JWT token: expired");
}
String subject = claims.getSubject();
if(!subject.equals("anonymous")) { //if anonymous, we don't look in user db
User user = User.get(subject, false, Collections.emptyMap());
if (user == null) {
throw new ServiceException.UnauthorizedException("Invalid JWT token: subject " + subject + " not found");
}
UserDetails d = Jenkins.getInstance().getSecurityRealm().loadUserByUsername(user.getId());
this.grantedAuthorities = d.getAuthorities();
}else{
this.grantedAuthorities = new GrantedAuthority[] {new GrantedAuthorityImpl("anonymous")};
}
this.name = subject;
} catch (InvalidJwtException e) {
logger.error("Invalid JWT token: "+e.getMessage(), e);
throw new ServiceException.UnauthorizedException("Invalid JWT token");
} catch (MalformedClaimException e) {
logger.error(String.format("Error reading sub header for token %s",jws.getPayload()),e);
throw new ServiceException.UnauthorizedException("Invalid JWT token: malformed claim");
}
} catch (JoseException e) {
logger.error("Error parsing JWT token: "+e.getMessage(), e);
throw new ServiceException.UnexpectedErrorException("Unexpected error");
}
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return "";
}
@Override
public Object getPrincipal() {
return name;
}
@Override
public String getName() {
return name;
}
@Override
public GrantedAuthority[] getAuthorities() {
return grantedAuthorities;
}
}

View File

@ -11,6 +11,8 @@ import io.jenkins.blueocean.rest.model.BlueFavoriteContainer;
import io.jenkins.blueocean.rest.model.BlueUser;
import jenkins.model.Jenkins;
import java.util.Collections;
/**
* {@link BlueUser} implementation backed by in-memory {@link User}
*
@ -43,7 +45,16 @@ public class UserImpl extends BlueUser {
@Override
public String getEmail() {
if (!user.hasPermission(Jenkins.ADMINISTER)) return null;
String name = Jenkins.getAuthentication().getName();
if(name.equals("anonymous") || user.getId().equals("anonymous")){
return null;
}else{
User user = User.get(name, false, Collections.EMPTY_MAP);
if(user == null){
return null;
}
if (!user.hasPermission(Jenkins.ADMINISTER)) return null;
}
Mailer.UserProperty p = user.getProperty(Mailer.UserProperty.class);
return p != null ? p.getAddress() : null;

View File

@ -6,11 +6,14 @@ import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.ObjectMapper;
import com.mashape.unirest.http.Unirest;
import com.mashape.unirest.http.exceptions.UnirestException;
import com.mashape.unirest.request.GetRequest;
import com.mashape.unirest.request.HttpRequest;
import com.mashape.unirest.request.HttpRequestWithBody;
import hudson.model.Job;
import hudson.model.Run;
import io.jenkins.blueocean.commons.JsonConverter;
import jenkins.model.Jenkins;
import net.sf.json.JSONObject;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
@ -26,6 +29,8 @@ import java.util.Date;
import java.util.Map;
import java.util.logging.LogManager;
import static io.jenkins.blueocean.auth.jwt.JwtToken.X_BLUEOCEAN_JWT;
/**
* @author Vivek Pandey
*/
@ -41,6 +46,8 @@ public abstract class BaseTest {
return "blue/rest";
}
protected String jwtToken;
@Before
public void setup() throws Exception {
if(System.getProperty("DISABLE_HTTP_HEADER_TRACE") == null) {
@ -48,6 +55,7 @@ public abstract class BaseTest {
LogManager.getLogManager().readConfiguration(is);
}
this.baseUrl = j.jenkins.getRootUrl() + getContextPath();
this.jwtToken = getJwtToken(j.jenkins);
Unirest.setObjectMapper(new ObjectMapper() {
public <T> T readValue(String value, Class<T> valueType) {
try {
@ -101,12 +109,13 @@ public abstract class BaseTest {
if(HttpResponse.class.isAssignableFrom(type)){
HttpResponse<String> response = Unirest.get(getBaseUrl(path)).header("Accept", accept)
.header("Accept-Encoding","")
.header("Authorization", "Bearer "+jwtToken)
.asString();
Assert.assertEquals(expectedStatus, response.getStatus());
return (T) response;
}
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept).asObject(type);
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept).header("Authorization", "Bearer "+jwtToken).asObject(type);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
} catch (UnirestException e) {
@ -118,7 +127,8 @@ public abstract class BaseTest {
protected Map delete(String path){
assert path.startsWith("/");
try {
HttpResponse<Map> response = Unirest.delete(getBaseUrl(path)).asObject(Map.class);
HttpResponse<Map> response = Unirest.delete(getBaseUrl(path))
.header("Authorization", "Bearer "+jwtToken).asObject(Map.class);
Assert.assertEquals(200, response.getStatus());
return response.getBody();
} catch (UnirestException e) {
@ -137,6 +147,7 @@ public abstract class BaseTest {
try {
HttpResponse<Map> response = Unirest.post(getBaseUrl(path))
.header("Content-Type","application/json")
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(Map.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -151,6 +162,7 @@ public abstract class BaseTest {
HttpResponse<String> response = Unirest.post(getBaseUrl(path))
.header("Content-Type",contentType)
.header("Accept-Encoding","")
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(String.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -171,6 +183,7 @@ public abstract class BaseTest {
HttpResponse<Map> response = Unirest.put(getBaseUrl(path))
.header("Content-Type","application/json")
.header("Accept","application/json")
.header("Authorization", "Bearer "+jwtToken)
//Unirest by default sets accept-encoding to gzip but stapler is sending malformed gzip value if
// the response length is small (in this case its 20 chars).
// Needs investigation in stapler to see whats going on there.
@ -190,6 +203,7 @@ public abstract class BaseTest {
try {
HttpResponse<String> response = Unirest.put(getBaseUrl(path))
.header("Content-Type",contentType)
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(String.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -207,6 +221,7 @@ public abstract class BaseTest {
try {
HttpResponse<Map> response = Unirest.patch(getBaseUrl(path))
.header("Content-Type","application/json")
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(Map.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -221,6 +236,7 @@ public abstract class BaseTest {
try {
HttpResponse<String> response = Unirest.patch(getBaseUrl(path))
.header("Content-Type",contentType)
.header("Authorization", "Bearer "+jwtToken)
.body(body).asObject(String.class);
Assert.assertEquals(expectedStatus, response.getStatus());
return response.getBody();
@ -300,7 +316,9 @@ public abstract class BaseTest {
public RequestBuilder request() {
return new RequestBuilder(baseUrl);
}
public static class RequestBuilder {
public class RequestBuilder {
private String url;
private String username;
private String method;
@ -310,6 +328,7 @@ public abstract class BaseTest {
private String baseUrl;
private int expectedStatus = 200;
private String token;
private String getBaseUrl(String path){
return baseUrl + path;
@ -341,6 +360,11 @@ public abstract class BaseTest {
return this;
}
public RequestBuilder jwtToken(String token){
this.token = token;
return this;
}
public RequestBuilder data(Map data) {
this.data = data;
@ -358,6 +382,11 @@ public abstract class BaseTest {
return this;
}
public RequestBuilder patch(String url) {
this.url = url;
this.method = "PATCH";
return this;
}
public RequestBuilder get(String url) {
this.url = url;
@ -387,6 +416,9 @@ public abstract class BaseTest {
case "PUT":
request = Unirest.put(getBaseUrl(url));
break;
case "PATCH":
request = Unirest.patch(getBaseUrl(url));
break;
case "POST":
request = Unirest.post(getBaseUrl(url));
break;
@ -407,6 +439,12 @@ public abstract class BaseTest {
request.basicAuth(username, password);
}
if(token == null) {
request.header("Authorization", "Bearer " + BaseTest.this.jwtToken);
}else{
request.header("Authorization", "Bearer " + token);
}
if(request instanceof HttpRequestWithBody && data != null) {
((HttpRequestWithBody)request).body(data);
}
@ -418,4 +456,39 @@ public abstract class BaseTest {
}
}
}
public static String getJwtToken(Jenkins jenkins) throws UnirestException {
HttpResponse<String> response = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
.header("Accept-Encoding","").asString();
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
Assert.assertNotNull(token);
//we do not validate it for test optimization and for the fact that there are separate
// tests that test token generation and validation
return token;
}
public static String getJwtToken(Jenkins jenkins, String username, String password) throws UnirestException {
GetRequest request = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
.header("Accept-Encoding","");
if(username!= null && password!= null){
request.basicAuth(username,password);
}
HttpResponse<String> response = request.asString();
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
Assert.assertNotNull(token);
//we do not validate it for test optimization and for the fact that there are separate
// tests that test token generation and validation
int i = token.indexOf('.');
Assert.assertTrue(i > 0);
int j = token.lastIndexOf(".");
Assert.assertTrue(j > 0);
String claim = new String(org.jose4j.base64url.Base64.decode(token.substring(i+1, j)));
Map u = JSONObject.fromObject(claim);
Assert.assertEquals(username,u.get("sub"));
return token;
}
}

View File

@ -1,5 +1,6 @@
package io.jenkins.blueocean.service.embedded;
import com.mashape.unirest.http.exceptions.UnirestException;
import org.junit.Assert;
import org.junit.Test;
@ -11,12 +12,12 @@ import java.util.Map;
*/
public class OrganizationApiTest extends BaseTest {
@Test
public void organizationUsers() {
public void organizationUsers() throws UnirestException {
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
hudson.model.User alice = j.jenkins.getUser("alice");
alice.setFullName("Alice Cooper");
List users = request().authAlice().get("/organizations/jenkins/users/").build(List.class);
List users = request().jwtToken(getJwtToken(j.jenkins,"alice", "alice")).get("/organizations/jenkins/users/").build(List.class);
Assert.assertEquals(users.size(), 1);
Assert.assertEquals(((Map)users.get(0)).get("id"), "alice");

View File

@ -6,6 +6,7 @@ import hudson.model.FreeStyleProject;
import hudson.model.Project;
import hudson.model.User;
import hudson.tasks.Mailer;
import jenkins.model.Jenkins;
import org.junit.Assert;
import org.junit.Test;
import org.jvnet.hudson.test.MockFolder;
@ -72,19 +73,52 @@ public class ProfileApiTest extends BaseTest{
@Test
public void patchMimeFailTest() throws Exception {
User system = j.jenkins.getUser("SYSTEM");
patch("/users/"+system.getId(), "","text/plain", 415);
new RequestBuilder(baseUrl)
.contentType("text/plain")
.status(415)
.patch("/users/"+system.getId())
.build(Map.class);
}
@Test
public void getUserDetailsTest() throws Exception {
hudson.model.User user = j.jenkins.getUser("alice");
user.setFullName("Alice Cooper");
user.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
hudson.model.User alice = j.jenkins.getUser("alice");
alice.setFullName("Alice Cooper");
alice.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
Map response = get("/users/"+user.getId());
Assert.assertEquals(user.getId(), response.get("id"));
Assert.assertEquals(user.getFullName(), response.get("fullName"));
Assert.assertEquals("alice@jenkins-ci.org", response.get("email"));
hudson.model.User bob = j.jenkins.getUser("bob");
bob.setFullName("Bob Smith");
bob.addProperty(new Mailer.UserProperty("bob@jenkins-ci.org"));
//Call is made as anonymous user, email should be null
Map response = get("/users/"+alice.getId());
Assert.assertEquals(alice.getId(), response.get("id"));
Assert.assertEquals(alice.getFullName(), response.get("fullName"));
Assert.assertNull(response.get("email"));
//make a request on bob's behalf to get alice's user details, should get null email
Map r = new RequestBuilder(baseUrl)
.status(200)
.jwtToken(getJwtToken(j.jenkins,"bob", "bob"))
.get("/users/"+alice.getId()).build(Map.class);
Assert.assertEquals(alice.getId(), r.get("id"));
Assert.assertEquals(alice.getFullName(), r.get("fullName"));
Assert.assertTrue(bob.hasPermission(Jenkins.ADMINISTER));
//bob is admin so can see alice email
Assert.assertEquals("alice@jenkins-ci.org",r.get("email"));
r = new RequestBuilder(baseUrl)
.status(200)
.jwtToken(getJwtToken(j.jenkins,"alice", "alice"))
.get("/users/"+alice.getId()).build(Map.class);
Assert.assertEquals(alice.getId(), r.get("id"));
Assert.assertEquals(alice.getFullName(), r.get("fullName"));
Assert.assertEquals("alice@jenkins-ci.org",r.get("email"));
}
@Test
@ -95,17 +129,17 @@ public class ProfileApiTest extends BaseTest{
Project p = j.createFreeStyleProject("pipeline1");
String token = getJwtToken(j.jenkins,"alice", "alice");
Map map = new RequestBuilder(baseUrl)
.put("/organizations/jenkins/pipelines/pipeline1/favorite")
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", true))
.build(Map.class);
validatePipeline(p, (Map) map.get("item"));
List l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(1, l.size());
@ -117,7 +151,7 @@ public class ProfileApiTest extends BaseTest{
Assert.assertEquals("/blue/rest/organizations/jenkins/pipelines/pipeline1/favorite/", href);
map = new RequestBuilder(baseUrl)
.put(href.substring("/blue/rest".length()))
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", false))
.build(Map.class);
@ -125,15 +159,14 @@ public class ProfileApiTest extends BaseTest{
l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(0, l.size());
new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("bob","bob")
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
.status(403)
.build(String.class);
@ -148,16 +181,17 @@ public class ProfileApiTest extends BaseTest{
MockFolder folder1 = j.createFolder("folder1");
Project p = folder1.createProject(FreeStyleProject.class, "pipeline1");
String token = getJwtToken(j.jenkins,"alice", "alice");
Map map = new RequestBuilder(baseUrl)
.put("/organizations/jenkins/pipelines/folder1/pipelines/pipeline1/favorite/")
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", true))
.build(Map.class);
validatePipeline(p, (Map) map.get("item"));
List l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(1, l.size());
@ -171,7 +205,7 @@ public class ProfileApiTest extends BaseTest{
map = new RequestBuilder(baseUrl)
.put(href.substring("/blue/rest".length()))
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", false))
.build(Map.class);
@ -179,7 +213,7 @@ public class ProfileApiTest extends BaseTest{
l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(0, l.size());
@ -187,14 +221,14 @@ public class ProfileApiTest extends BaseTest{
map = new RequestBuilder(baseUrl)
.put("/organizations/jenkins/pipelines/folder1/favorite/")
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", true))
.build(Map.class);
validateFolder(folder1, (Map) map.get("item"));
l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(1, l.size());
@ -208,7 +242,7 @@ public class ProfileApiTest extends BaseTest{
map = new RequestBuilder(baseUrl)
.put(href.substring("/blue/rest".length()))
.auth("alice", "alice")
.jwtToken(token)
.data(ImmutableMap.of("favorite", false))
.build(Map.class);
@ -216,7 +250,7 @@ public class ProfileApiTest extends BaseTest{
l = new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("alice","alice")
.jwtToken(token)
.build(List.class);
Assert.assertEquals(0, l.size());
@ -225,7 +259,7 @@ public class ProfileApiTest extends BaseTest{
new RequestBuilder(baseUrl)
.get("/users/"+user.getId()+"/favorites/")
.auth("bob","bob")
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
.status(403)
.build(String.class);
@ -262,9 +296,11 @@ public class ProfileApiTest extends BaseTest{
user.setFullName("Alice Cooper");
user.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
String token = getJwtToken(j.jenkins,"alice", "alice");
Map u = new RequestBuilder(baseUrl)
.get("/organizations/jenkins/user/")
.auth("alice","alice")
.jwtToken(token)
.status(200)
.build(Map.class);
@ -285,10 +321,11 @@ public class ProfileApiTest extends BaseTest{
user1.setFullName("Bob Cooper");
user1.addProperty(new Mailer.UserProperty("bob@jenkins-ci.org"));
new RequestBuilder(baseUrl)
Map u = new RequestBuilder(baseUrl)
.get("/organizations/jenkins/user/")
.status(404)
.build(Map.class);
Assert.assertEquals("anonymous",u.get("id"));
}
}

View File

@ -7,6 +7,7 @@
- [Media Type](#media-type)
- [Date Format](#date-format)
- [Crumbs](#crumbs)
- [Security](#security)
- [Navigability](#navigability)
- [Links](#links)
- [Resource discovery](#resource-discovery)
@ -103,7 +104,20 @@ All date formats are in ISO 8601 format
Jenkins usually requires a "crumb" with posted requests to prevent request forgery and other shenanigans.
To avoid needing a crumb to POST data, the header `Content-Type: application/json` *must* be used.
# Security
BlueOcean REST APIs requires JWT token for authentication. JWT APIs are provided by blueocean-jwt plugin. See
[JWT APIs](../blueocean-jwt/README.md) to get JWT token and to get public key needed to verify the claims.
JWT token must be sent as bearer token as value of HTTP 'Authorization' header:
curl -H 'Authorization: Bearer eyJraWQ...' http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/
To disable JWT authentication use DISABLE_BLUEOCEAN_JWT_AUTHENTICATION=true system property.
mvn hpi:run -DDISABLE_BLUEOCEAN_JWT_AUTHENTICATION=true
# Navigability
## Links

View File

@ -106,6 +106,7 @@
<module>blueocean-personalization</module>
<module>blueocean-plugin</module>
<module>blueocean-analytics-tools</module>
<module>blueocean-jwt</module>
</modules>
<repositories>
@ -185,6 +186,13 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>blueocean-jwt</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 3rd party dependencies -->
<dependency>