-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
18577d1
commit b46165c
Showing
13 changed files
with
659 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
src/main/java/hudson/plugins/git/ApiTokenPropertyConfiguration.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
package hudson.plugins.git; | ||
|
||
import edu.umd.cs.findbugs.annotations.NonNull; | ||
import hudson.Extension; | ||
import hudson.Util; | ||
import hudson.model.PersistentDescriptor; | ||
import hudson.util.HttpResponses; | ||
import jenkins.model.GlobalConfiguration; | ||
import jenkins.model.GlobalConfigurationCategory; | ||
import jenkins.model.Jenkins; | ||
import net.jcip.annotations.GuardedBy; | ||
import net.sf.json.JSONObject; | ||
import org.apache.commons.lang.StringUtils; | ||
import org.jenkinsci.Symbol; | ||
import org.kohsuke.accmod.Restricted; | ||
import org.kohsuke.accmod.restrictions.NoExternalUse; | ||
import org.kohsuke.stapler.HttpResponse; | ||
import org.kohsuke.stapler.StaplerRequest; | ||
import org.kohsuke.stapler.interceptor.RequirePOST; | ||
|
||
import java.io.Serializable; | ||
import java.nio.charset.StandardCharsets; | ||
import java.security.MessageDigest; | ||
import java.security.NoSuchAlgorithmException; | ||
import java.security.SecureRandom; | ||
import java.util.ArrayList; | ||
import java.util.Collection; | ||
import java.util.Collections; | ||
import java.util.List; | ||
import java.util.UUID; | ||
import java.util.logging.Level; | ||
import java.util.logging.Logger; | ||
|
||
|
||
@Extension | ||
@Restricted(NoExternalUse.class) | ||
@Symbol("apiTokenProperty") | ||
public class ApiTokenPropertyConfiguration extends GlobalConfiguration implements PersistentDescriptor { | ||
|
||
private static final Logger LOGGER = Logger.getLogger(ApiTokenPropertyConfiguration.class.getName()); | ||
private static final SecureRandom RANDOM = new SecureRandom(); | ||
private static final String HASH_ALGORITHM = "SHA-256"; | ||
|
||
@GuardedBy("this") | ||
private final List<HashedApiToken> apiTokens; | ||
|
||
public ApiTokenPropertyConfiguration() { | ||
this.apiTokens = new ArrayList<>(); | ||
} | ||
|
||
public static ApiTokenPropertyConfiguration get() { | ||
return GlobalConfiguration.all().get(ApiTokenPropertyConfiguration.class); | ||
} | ||
|
||
@NonNull | ||
@Override | ||
public GlobalConfigurationCategory getCategory() { | ||
return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class); | ||
} | ||
|
||
@RequirePOST | ||
public HttpResponse doGenerate(StaplerRequest req) { | ||
Jenkins.get().checkPermission(Jenkins.ADMINISTER); | ||
|
||
String apiTokenName = req.getParameter("apiTokenName"); | ||
JSONObject json = this.generateApiToken(apiTokenName); | ||
save(); | ||
|
||
return HttpResponses.okJSON(json); | ||
} | ||
|
||
public JSONObject generateApiToken(@NonNull String name) { | ||
byte[] random = new byte[16]; | ||
RANDOM.nextBytes(random); | ||
|
||
String plainTextApiToken = Util.toHexString(random); | ||
assert plainTextApiToken.length() == 32; | ||
|
||
String apiTokenValueHashed = Util.toHexString(hashedBytes(plainTextApiToken.getBytes(StandardCharsets.US_ASCII))); | ||
HashedApiToken apiToken = new HashedApiToken(name, apiTokenValueHashed); | ||
|
||
synchronized (this) { | ||
this.apiTokens.add(apiToken); | ||
} | ||
|
||
JSONObject json = new JSONObject(); | ||
json.put("uuid", apiToken.getUuid()); | ||
json.put("name", apiToken.getName()); | ||
json.put("value", plainTextApiToken); | ||
|
||
return json; | ||
} | ||
|
||
@NonNull | ||
private static byte[] hashedBytes(byte[] tokenBytes) { | ||
MessageDigest digest; | ||
try { | ||
digest = MessageDigest.getInstance(HASH_ALGORITHM); | ||
} catch (NoSuchAlgorithmException e) { | ||
throw new AssertionError("There is no " + HASH_ALGORITHM + " available in this system", e); | ||
} | ||
return digest.digest(tokenBytes); | ||
} | ||
|
||
@RequirePOST | ||
public HttpResponse doRevoke(StaplerRequest req) { | ||
Jenkins.get().checkPermission(Jenkins.ADMINISTER); | ||
|
||
String apiTokenUuid = req.getParameter("apiTokenUuid"); | ||
if (StringUtils.isBlank(apiTokenUuid)) { | ||
return HttpResponses.errorWithoutStack(400, "API token UUID cannot be empty"); | ||
} | ||
|
||
synchronized (this) { | ||
this.apiTokens.removeIf(apiToken -> apiToken.getUuid().equals(apiTokenUuid)); | ||
} | ||
save(); | ||
|
||
return HttpResponses.ok(); | ||
} | ||
|
||
public synchronized Collection<HashedApiToken> getApiTokens() { | ||
return Collections.unmodifiableList(new ArrayList<>(this.apiTokens)); | ||
} | ||
|
||
public boolean isValidApiToken(String plainApiToken) { | ||
if (StringUtils.isBlank(plainApiToken)) { | ||
return false; | ||
} | ||
|
||
return this.hasMatchingApiToken(plainApiToken); | ||
} | ||
|
||
public synchronized boolean hasMatchingApiToken(@NonNull String plainApiToken) { | ||
byte[] hash = hashedBytes(plainApiToken.getBytes(StandardCharsets.US_ASCII)); | ||
return this.apiTokens.stream().anyMatch(apiToken -> apiToken.match(hash)); | ||
} | ||
|
||
public static class HashedApiToken implements Serializable { | ||
|
||
private static final long serialVersionUID = 1L; | ||
|
||
private final String uuid; | ||
private final String name; | ||
private final String hash; | ||
|
||
private HashedApiToken(String name, String hash) { | ||
this.uuid = UUID.randomUUID().toString(); | ||
this.name = name; | ||
this.hash = hash; | ||
} | ||
|
||
private HashedApiToken(String uuid, String name, String hash) { | ||
this.uuid = uuid; | ||
this.name = name; | ||
this.hash = hash; | ||
} | ||
|
||
public String getUuid() { | ||
return uuid; | ||
} | ||
|
||
public String getName() { | ||
return name; | ||
} | ||
|
||
public String getHash() { | ||
return hash; | ||
} | ||
|
||
private boolean match(byte[] hashedBytes) { | ||
byte[] hashFromHex; | ||
try { | ||
hashFromHex = Util.fromHexString(hash); | ||
} catch (NumberFormatException e) { | ||
LOGGER.log(Level.INFO, "The API token with name=[{0}] is not in hex-format and so cannot be used", name); | ||
return false; | ||
} | ||
|
||
return MessageDigest.isEqual(hashFromHex, hashedBytes); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
54 changes: 54 additions & 0 deletions
54
src/main/resources/hudson/plugins/git/ApiTokenPropertyConfiguration/config.jelly
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
<?jelly escape-by-default='true'?> | ||
|
||
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:f="/lib/form" xmlns:l="/lib/layout"> | ||
<f:section title="${%Git plugin notifyCommit access tokens}"> | ||
<st:adjunct includes="hudson.plugins.git.ApiTokenPropertyConfiguration.resources" /> | ||
<f:entry title="${%Current access tokens}" help="${descriptor.getHelpFile('tokens')}"> | ||
<div class="api-token-list"> | ||
<j:set var="apiTokens" value="${instance.apiTokens}" /> | ||
<div class="api-token-list-empty-item ${apiTokens == null || apiTokens.isEmpty() ? '' : 'hidden'}"> | ||
<div class="list-empty-message">${%There are no access tokens yet.}</div> | ||
</div> | ||
<f:repeatable var="apiToken" items="${apiTokens}" minimum="0" add="${%Add new access token}"> | ||
<j:choose> | ||
<j:when test="${apiToken != null}"> | ||
<input type="hidden" class="api-token-uuid-input" name="apiTokenUuid" value="${apiToken.uuid}" /> | ||
<div class="api-token-list-item-row api-token-list-existing-token"> | ||
<f:textbox readonly="true" value="${apiToken.name}" /> | ||
<a href="#" onclick="return revokeApiToken(this)" class="yui-button api-token-revoke-button" | ||
data-confirm="${%Are you sure you want to revoke this access token?}" | ||
data-target-url="${descriptor.descriptorFullUrl}/revoke"> | ||
${%Revoke} | ||
</a> | ||
</div> | ||
</j:when> | ||
<j:otherwise> | ||
<div class="api-token-list-item"> | ||
<div class="api-token-list-item-row"> | ||
<input type="hidden" class="api-token-uuid-input" name="apiTokenUuid" value="${apiToken.uuid}" /> | ||
<f:textbox clazz="api-token-name-input" name="apiTokenName" placeholder="${%Access token name}"/> | ||
<span class="new-api-token-value hidden"><!-- to be filled by JS --></span> | ||
<span class="yui-button api-token-save-button"> | ||
<button type="button" tabindex="0" data-target-url="${descriptor.descriptorFullUrl}/generate" onclick="saveApiToken(this)"> | ||
${%Generate} | ||
</button> | ||
</span> | ||
<span class="api-token-cancel-button"> | ||
<f:repeatableDeleteButton value="${%Cancel}" /> | ||
</span> | ||
<l:copyButton message="${%Copied}" text="" clazz="hidden" tooltip="${%Copy to clipboard}" /> | ||
<a href="#" onclick="return revokeApiToken(this)" class="yui-button api-token-revoke-button hidden" | ||
data-confirm="${%Are you sure you want to revoke this access token?}" | ||
data-target-url="${descriptor.descriptorFullUrl}/revoke"> | ||
${%Revoke} | ||
</a> | ||
</div> | ||
<span class="warning api-token-warning-message hidden">${%Access token will only be displayed once.}</span> | ||
</div> | ||
</j:otherwise> | ||
</j:choose> | ||
</f:repeatable> | ||
</div> | ||
</f:entry> | ||
</f:section> | ||
</j:jelly> |
15 changes: 15 additions & 0 deletions
15
src/main/resources/hudson/plugins/git/ApiTokenPropertyConfiguration/help-tokens.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
<div> | ||
<p>These access tokens serve as a way of authenticating requests to the <code>notifyCommit</code> endpoint. | ||
<p>By default, all requests to <code>notifyCommit</code> must include a valid token in the <code>token</code> query parameter. However, it is possible to disable | ||
that requirement with the <a href="https://www.jenkins.io/doc/book/managing/system-properties/">system property</a>: | ||
<pre><code>hudson.plugins.git.GitStatus.NOTIFY_COMMIT_ACCESS_CONTROL</code></pre> | ||
<br/> | ||
It has two modes: | ||
<ul> | ||
<li><code>disabled-for-polling</code> - Allows unauthenticated requests as long as they only request polling of the repository supplied in the | ||
<code>url</code> query parameter. Prohibits unauthenticated requests that attempt to schedule a build immediately by providing a | ||
<code>sha1</code> query parameter.</li> | ||
<li><code>disabled</code> - Fully disables the access token mechanism and allows all requests to <code>notifyCommit</code> | ||
to be unauthenticated. <b>This option is insecure and is not recommended.</b></li> | ||
</ul> | ||
</div> |
18 changes: 18 additions & 0 deletions
18
src/main/resources/hudson/plugins/git/ApiTokenPropertyConfiguration/resources.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
.api-token-list .api-token-list-item-row { | ||
display: flex; | ||
align-items: center; | ||
max-width: 700px; | ||
} | ||
.api-token-list .api-token-list-item-row.api-token-list-existing-api-token { | ||
justify-content: space-between; | ||
} | ||
.api-token-list .api-token-list-item .hidden, .api-token-list .api-token-list-empty-item.hidden { | ||
display: none; | ||
} | ||
|
||
.api-token-list .api-token-revoke-button, .api-token-list .new-api-token-value { | ||
padding: 0 0.5rem; | ||
} | ||
.api-token-list .api-token-warning-message, .api-token-list .api-token-save-button { | ||
margin: 0.5rem 0; | ||
} |
Oops, something went wrong.