Skip to content

Commit

Permalink
[FIXED JENKINS-9363] added API token for REST API.
Browse files Browse the repository at this point in the history
  • Loading branch information
kohsuke committed Aug 6, 2011
1 parent 2903852 commit 578a2f5
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 3 deletions.
3 changes: 3 additions & 0 deletions changelog.html
Expand Up @@ -60,6 +60,9 @@
(<a href="https://issues.jenkins-ci.org/browse/JENKINS-10556">issue 10556</a>)
<li class=rfe>
Record and display who aborted builds.
<li class=rfe>
Added API token support.
(<a href="https://issues.jenkins-ci.org/browse/JENKINS-9363">issue 9363</a>)
</ul>
</div><!--=TRUNK-END=-->

Expand Down
21 changes: 19 additions & 2 deletions core/src/main/java/hudson/security/BasicAuthenticationFilter.java
Expand Up @@ -23,8 +23,10 @@
*/
package hudson.security;

import hudson.model.User;
import jenkins.model.Jenkins;
import hudson.util.Scrambler;
import jenkins.security.ApiTokenProperty;
import org.acegisecurity.context.SecurityContextHolder;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
Expand All @@ -46,9 +48,9 @@
* Implements the dual authentcation mechanism.
*
* <p>
* Hudson supports both the HTTP basic authentication and the form-based authentication.
* Jenkins supports both the HTTP basic authentication and the form-based authentication.
* The former is for scripted clients, and the latter is for humans. Unfortunately,
* becase the servlet spec does not allow us to programatically authenticate users,
* because the servlet spec does not allow us to programatically authenticate users,
* we need to rely on some hack to make it work, and this is the class that implements
* that hack.
*
Expand Down Expand Up @@ -131,6 +133,21 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
return;
}

{// attempt to authenticate as API token
User u = User.get(username);
ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
if (t!=null && t.matchesPassword(password)) {
SecurityContextHolder.getContext().setAuthentication(u.impersonate());
try {
chain.doFilter(request,response);
} finally {
SecurityContextHolder.clearContext();
}
return;
}
}


path = req.getContextPath()+"/secured"+path;
String q = req.getQueryString();
if(q!=null)
Expand Down
66 changes: 66 additions & 0 deletions core/src/main/java/jenkins/security/ApiTokenFilter.java
@@ -0,0 +1,66 @@
package jenkins.security;

import hudson.model.User;
import hudson.util.Scrambler;
import org.acegisecurity.context.SecurityContextHolder;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* {@link Filter} that performs HTTP basic authentication based on API token.
*
* <p>
* Normally the filter chain would also contain another filter that handles BASIC
* auth with the real password. Care must be taken to ensure that this doesn't
* interfere with the other.
*
* @author Kohsuke Kawaguchi
*/
public class ApiTokenFilter implements Filter {
public void init(FilterConfig filterConfig) throws ServletException {
}

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse rsp = (HttpServletResponse) response;
String authorization = req.getHeader("Authorization");

if (authorization!=null) {
// authenticate the user
String uidpassword = Scrambler.descramble(authorization.substring(6));
int idx = uidpassword.indexOf(':');
if (idx >= 0) {
String username = uidpassword.substring(0, idx);
String password = uidpassword.substring(idx+1);

// attempt to authenticate as API token
User u = User.get(username);
ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
if (t!=null && t.matchesPassword(password)) {
// even if we fail to match the password, we aren't rejecting it.
// as the user might be passing in a real password.
SecurityContextHolder.getContext().setAuthentication(u.impersonate());
try {
chain.doFilter(request,response);
return;
} finally {
SecurityContextHolder.clearContext();
}
}
}
}

chain.doFilter(request,response);
}

public void destroy() {
}
}
128 changes: 128 additions & 0 deletions core/src/main/java/jenkins/security/ApiTokenProperty.java
@@ -0,0 +1,128 @@
/*
* The MIT License
*
* Copyright (c) 2011, CloudBees, Inc.
*
* 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.
*/
package jenkins.security;

import hudson.Extension;
import hudson.Functions;
import hudson.Util;
import hudson.model.Descriptor.FormException;
import hudson.model.User;
import hudson.model.UserProperty;
import hudson.model.UserPropertyDescriptor;
import hudson.util.FormValidation;
import hudson.util.HttpResponses;
import hudson.util.Secret;
import jenkins.model.Jenkins;
import net.sf.json.JSONObject;
import org.kohsuke.stapler.AncestorInPath;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;

import java.io.IOException;
import java.security.SecureRandom;

/**
* Remembers the API token for this user, that can be used like a password to login.
*
*
* @author Kohsuke Kawaguchi
* @see ApiTokenFilter
* @since 1.426
*/
public class ApiTokenProperty extends UserProperty {
private volatile Secret apiToken;

@DataBoundConstructor
public ApiTokenProperty() {
_changeApiToken();
}

/**
* We don't let the external code set the API token,
* but for the initial value of the token we need to compute the seed by ourselves.
*/
private ApiTokenProperty(String seed) {
apiToken = Secret.fromString(seed);
}

public String getApiToken() {
return Util.getDigestOf(apiToken.getPlainText());
}

public boolean matchesPassword(String password) {
return getApiToken().equals(password);
}

public void changeApiToken() throws IOException {
_changeApiToken();
if (user!=null)
user.save();
}

private void _changeApiToken() {
byte[] random = new byte[16]; // 16x8=128bit worth of randomness, since we use md5 digest as the API token
RANDOM.nextBytes(random);
apiToken = Secret.fromString(Util.toHexString(random));
}

@Override
public UserProperty reconfigure(StaplerRequest req, JSONObject form) throws FormException {
return this;
}

@Extension
public static final class DescriptorImpl extends UserPropertyDescriptor {
public String getDisplayName() {
return "API Token";
}

/**
* When we are creating a default {@link ApiTokenProperty} for User,
* we need to make sure it yields the same value for the same user,
* because there's no guarantee that the property is saved.
*
* But we also need to make sure that an attacker won't be able to guess
* the initial API token value. So we take the seed by hasing the instance secret key + user ID.
*/
public ApiTokenProperty newInstance(User user) {
return new ApiTokenProperty(Util.getDigestOf(Jenkins.getInstance().getSecretKey() + ":" + user.getId()));
}

public HttpResponse doChangeToken(@AncestorInPath User u, StaplerResponse rsp) throws IOException {
ApiTokenProperty p = u.getProperty(ApiTokenProperty.class);
if (p==null) {
p = newInstance(u);
u.addProperty(p);
} else {
p.changeApiToken();
}
rsp.setHeader("script","document.getElementById('apiToken').value='"+p.getApiToken()+"'");
return HttpResponses.html("<div>Updated</div>");
}
}

private static final SecureRandom RANDOM = new SecureRandom();
}
@@ -0,0 +1,48 @@
/*
* The MIT License
*
* Copyright (c) 2011, CloudBees, Inc.
*
* 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.
*/
package jenkins.security.ApiTokenProperty;

f=namespace(lib.FormTagLib)

f.advanced(title:"Show API Token", align:"left") {
f.entry(title:_("API Token"), field:"apiToken") {
f.readOnlyTextbox(id:"apiToken") // TODO: need to figure out the way to do this without using ID.
}
f.validateButton(title:"Change API Token",method:"changeToken")

This comment has been minimized.

Copy link
@ssogabe

ssogabe Aug 12, 2011

Member

When I click "Change API Link", I get following exception.

Change API Token
ERROR
404 Not Found

Stapler processed this HTTP request as follows, but couldn't find the resource to consume the request

-> evaluate(hudson.model.Hudson@3cfd4bba :hudson.model.Hudson,"/jenkins/user/Seiji%20Sogabe/descriptorByName/jenkins.security.ApiTokenProperty/changeToken")
-> evaluate(((StaplerProxy)hudson.model.Hudson@3cfd4bba).getTarget(),"/jenkins/user/Seiji%20Sogabe/descriptorByName/jenkins.security.ApiTokenProperty/changeToken")
-> evaluate(hudson.model.Hudson@3cfd4bba.getDynamic("jenkins",...),"/user/Seiji%20Sogabe/descriptorByName/jenkins.security.ApiTokenProperty/changeToken")
hudson.model.Hudson@3cfd4bba.getDynamic("jenkins",...)==null. Back tracking.
-> evaluate(((StaplerFallback)hudson.model.Hudson@3cfd4bba).getStaplerFallback(),"/jenkins/user/Seiji%20Sogabe/descriptorByName/jenkins.security.ApiTokenProperty/changeToken")
-> evaluate(hudson.model.ListView@3f593669 :hudson.model.ListView,"/jenkins/user/Seiji%20Sogabe/descriptorByName/jenkins.security.ApiTokenProperty/changeToken")
-> evaluate(hudson.model.ListView@3f593669.getDynamic("jenkins",...),"/user/Seiji%20Sogabe/descriptorByName/jenkins.security.ApiTokenProperty/changeToken")
hudson.model.ListView@3f593669.getDynamic("jenkins",...)==null. Back tracking.
-> No matching rule was found on hudson.model.ListView@3f593669 for "/jenkins/user/Seiji%20Sogabe/descriptorByName/jenkins.security.ApiTokenProperty/changeToken"

}

//f.entry(title:_("API Token"),field:"apiToken") {
//raw("""
//<a href="#" class='showDetails'>${_("Show API token")}</a><div style="display:none">
//""")
// f.readOnlyTextbox()
//raw("""
//</div>
//""")
//}
//
//f.validateButton(title:"") {
//
//}
//
@@ -0,0 +1,5 @@
<div>
This API token can be used for authenticating yourself in the REST API call.
See <a href="https://wiki.jenkins-ci.org/display/JENKINS/Remote+access+API">our wiki</a> for more details.
The API token should be protected like your password, as it allows other people to access Jenkins as you.
</div>
63 changes: 63 additions & 0 deletions test/src/main/java/jenkins/security/ApiTokenPropertyTest.java
@@ -0,0 +1,63 @@
package jenkins.security;

import com.gargoylesoftware.htmlunit.HttpWebConnection;
import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import hudson.model.User;
import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
import org.apache.commons.httpclient.auth.AuthScheme;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.auth.CredentialsNotAvailableException;
import org.apache.commons.httpclient.auth.CredentialsProvider;
import org.jvnet.hudson.test.HudsonTestCase;

import java.util.concurrent.Callable;

/**
* @author Kohsuke Kawaguchi
*/
public class ApiTokenPropertyTest extends HudsonTestCase {
/**
* Tests the UI interaction and authentication.
*/
public void testBasics() throws Exception {
jenkins.setSecurityRealm(createDummySecurityRealm());
User u = User.get("foo");
ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
final String token = t.getApiToken();

// make sure the UI shows the token
HtmlPage config = createWebClient().goTo(u.getUrl() + "/configure");
HtmlForm form = config.getFormByName("config");
assertEquals(token, form.getInputByName("_.apiToken").getValueAttribute());

// round-trip shouldn't change the API token
submit(form);
assertSame(t, u.getProperty(ApiTokenProperty.class));

WebClient wc = createWebClient();
wc.setCredentialsProvider(new CredentialsProvider() {
public Credentials getCredentials(AuthScheme scheme, String host, int port, boolean proxy) throws CredentialsNotAvailableException {
return new UsernamePasswordCredentials("foo", token);
}
});
wc.setWebConnection(new HttpWebConnection(wc) {
@Override
protected HttpClient getHttpClient() {
HttpClient c = super.getHttpClient();
c.getParams().setAuthenticationPreemptive(true);
c.getState().setCredentials(new AuthScope("localhost", localPort, AuthScope.ANY_REALM), new UsernamePasswordCredentials("foo", token));
return c;
}
});

// test the authentication
assertEquals(u,wc.executeOnServer(new Callable<User>() {
public User call() throws Exception {
return User.current();
}
}));
}
}
4 changes: 3 additions & 1 deletion war/src/main/webapp/WEB-INF/security/SecurityFilters.groovy
Expand Up @@ -40,6 +40,7 @@ import org.acegisecurity.ui.rememberme.RememberMeProcessingFilter
import hudson.security.HttpSessionContextIntegrationFilter2
import hudson.security.SecurityRealm
import hudson.security.NoopFilter
import jenkins.security.ApiTokenFilter

// providers that apply to both patterns
def commonProviders() {
Expand All @@ -63,6 +64,7 @@ filter(ChainedServletFilter) {
// this persists the authentication across requests by using session
bean(HttpSessionContextIntegrationFilter2) {
},
bean(ApiTokenFilter),
// allow clients to submit basic authentication credential
// but allow that to be skipped since it can interfere with reverse proxy setup
Boolean.getBoolean("jenkins.security.ignoreBasicAuth") ? bean(NoopFilter) :
Expand All @@ -73,7 +75,7 @@ filter(ChainedServletFilter) {
// since users of basic auth tends to be a program and won't see the redirection to the form
// page as a failure
authenticationEntryPoint = bean(BasicProcessingFilterEntryPoint) {
realmName = "Hudson"
realmName = "Jenkins"
}
},
bean(AuthenticationProcessingFilter2) {
Expand Down

0 comments on commit 578a2f5

Please sign in to comment.