Skip to content

Commit 5943cde

Browse files
committed
API tokens with expiration
API tokens can now get an optional expiration date. Added a select drop down from where you can choose from predefined durations, a custom date (max 1 year) and no expiration. Expiration date is shown in the list. Not expiring tokens are marked with warning. fixes jenkinsci#16695
1 parent 2fa902c commit 5943cde

File tree

11 files changed

+319
-26
lines changed

11 files changed

+319
-26
lines changed

core/src/main/java/jenkins/install/SetupWizard.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ public SetupWizard() {
112112
* E.g. 110123456789abcdef0123456789abcdef.
113113
* A fixed API Token will be created for the user with that plain value as the token.
114114
* It is strongly recommended to use it to generate a new one (random) and then revoke it.
115-
* See {@link ApiTokenProperty#generateNewToken(String)} and {@link ApiTokenProperty#revokeAllTokensExceptOne(String)}
115+
* See {@link ApiTokenProperty#generateNewToken(String, java.util.Date)} and {@link ApiTokenProperty#revokeAllTokensExceptOne(String)}
116116
* for scripting methods or using the web API calls:
117117
* /user/[user-login]/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken and
118118
* /user/[user-login]/descriptorByName/jenkins.security.ApiTokenProperty/revokeAllExcept
@@ -216,7 +216,7 @@ private void createInitialApiToken(User user) throws IOException, InterruptedExc
216216

217217
String sysProp = ADMIN_INITIAL_API_TOKEN;
218218
if (sysProp.equals("true")) {
219-
TokenUuidAndPlainValue tokenUuidAndPlainValue = apiTokenProperty.generateNewToken("random-generation-during-setup-wizard");
219+
TokenUuidAndPlainValue tokenUuidAndPlainValue = apiTokenProperty.generateNewToken("random-generation-during-setup-wizard", null);
220220
FilePath fp = getInitialAdminApiTokenFile();
221221
// same comment as in the init method
222222

core/src/main/java/jenkins/security/ApiTokenProperty.java

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import edu.umd.cs.findbugs.annotations.CheckForNull;
2828
import edu.umd.cs.findbugs.annotations.NonNull;
29+
import edu.umd.cs.findbugs.annotations.Nullable;
2930
import hudson.Extension;
3031
import hudson.Util;
3132
import hudson.model.Descriptor.FormException;
@@ -255,13 +256,20 @@ public static class TokenInfoAndStats {
255256
public final int useCounter;
256257
public final Date lastUseDate;
257258
public final long numDaysUse;
259+
public final String expirationDate;
258260

259261
public TokenInfoAndStats(@NonNull ApiTokenStore.HashedToken token, @NonNull ApiTokenStats.SingleTokenStats stats) {
260262
this.uuid = token.getUuid();
261263
this.name = token.getName();
262264
this.creationDate = token.getCreationDate();
263265
this.numDaysCreation = token.getNumDaysCreation();
264266
this.isLegacy = token.isLegacy();
267+
LocalDate expirationDate = token.getExpirationDate();
268+
if (expirationDate == null) {
269+
this.expirationDate = "never";
270+
} else {
271+
this.expirationDate = expirationDate.toString();
272+
}
265273

266274
this.useCounter = stats.getUseCounter();
267275
this.lastUseDate = stats.getLastUseDate();
@@ -424,15 +432,27 @@ public ApiTokenStats getTokenStats() {
424432
// essentially meant for scripting
425433
@Restricted(Beta.class)
426434
public @NonNull String addFixedNewToken(@NonNull String name, @NonNull String tokenPlainValue) throws IOException {
427-
String tokenUuid = this.tokenStore.addFixedNewToken(name, tokenPlainValue);
435+
return addFixedNewToken(name, tokenPlainValue, null);
436+
}
437+
438+
// essentially meant for scripting
439+
@Restricted(Beta.class)
440+
public @NonNull String addFixedNewToken(@NonNull String name, @NonNull String tokenPlainValue, @Nullable LocalDate expirationDate) throws IOException {
441+
String tokenUuid = this.tokenStore.addFixedNewToken(name, tokenPlainValue, expirationDate);
428442
user.save();
429443
return tokenUuid;
430444
}
431445

432446
// essentially meant for scripting
433447
@Restricted(Beta.class)
434448
public @NonNull TokenUuidAndPlainValue generateNewToken(@NonNull String name) throws IOException {
435-
TokenUuidAndPlainValue tokenUuidAndPlainValue = tokenStore.generateNewToken(name);
449+
return generateNewToken(name, null);
450+
}
451+
452+
// essentially meant for scripting
453+
@Restricted(Beta.class)
454+
public @NonNull TokenUuidAndPlainValue generateNewToken(@NonNull String name, @Nullable LocalDate expirationDate) throws IOException {
455+
TokenUuidAndPlainValue tokenUuidAndPlainValue = tokenStore.generateNewToken(name, expirationDate);
436456
user.save();
437457
return tokenUuidAndPlainValue;
438458
}
@@ -535,7 +555,7 @@ public boolean hasCurrentUserRightToGenerateNewToken(User propertyOwner) {
535555
}
536556

537557
/**
538-
* @deprecated use {@link #doGenerateNewToken(User, String)} instead
558+
* @deprecated use {@link #doGenerateNewToken(User, String, String, String)} instead
539559
*/
540560
@Deprecated
541561
@RequirePOST
@@ -567,7 +587,8 @@ public HttpResponse doChangeToken(@AncestorInPath User u, StaplerResponse rsp) t
567587
}
568588

569589
@RequirePOST
570-
public HttpResponse doGenerateNewToken(@AncestorInPath User u, @QueryParameter String newTokenName) throws IOException {
590+
public HttpResponse doGenerateNewToken(@AncestorInPath User u, @QueryParameter String newTokenName,
591+
@QueryParameter String tokenExpiration, @QueryParameter String expirationDuration) throws IOException {
571592
if (!hasCurrentUserRightToGenerateNewToken(u)) {
572593
return HttpResponses.forbidden();
573594
}
@@ -579,21 +600,49 @@ public HttpResponse doGenerateNewToken(@AncestorInPath User u, @QueryParameter S
579600
tokenName = newTokenName;
580601
}
581602

603+
LocalDate expirationDate = getExpirationDate(tokenExpiration, expirationDuration);
604+
582605
ApiTokenProperty p = u.getProperty(ApiTokenProperty.class);
583606
if (p == null) {
584607
p = forceNewInstance(u, false);
585608
u.addProperty(p);
586609
}
587610

588-
TokenUuidAndPlainValue tokenUuidAndPlainValue = p.generateNewToken(tokenName);
611+
TokenUuidAndPlainValue tokenUuidAndPlainValue = p.generateNewToken(tokenName, expirationDate);
612+
String expirationDateString = "never";
613+
if (expirationDate != null) {
614+
expirationDateString = expirationDate.toString();
615+
}
589616

590617
Map<String, String> data = new HashMap<>();
591618
data.put("tokenUuid", tokenUuidAndPlainValue.tokenUuid);
592619
data.put("tokenName", tokenName);
593620
data.put("tokenValue", tokenUuidAndPlainValue.plainValue);
621+
data.put("expirationDate", expirationDateString);
594622
return HttpResponses.okJSON(data);
595623
}
596624

625+
private LocalDate getExpirationDate(String tokenExpiration, String expirationDuration) {
626+
if (expirationDuration == null) {
627+
expirationDuration = "";
628+
}
629+
expirationDuration = expirationDuration.trim();
630+
631+
return switch (expirationDuration) {
632+
case "", "never" -> {
633+
yield null;
634+
}
635+
case "custom" -> {
636+
yield LocalDate.parse(tokenExpiration);
637+
}
638+
default -> {
639+
LocalDate now = LocalDate.now();
640+
int days = Integer.parseInt(expirationDuration);
641+
yield now.plusDays(days);
642+
}
643+
};
644+
645+
}
597646
/**
598647
* This method is dangerous and should not be used without caution.
599648
* The token passed here could have been tracked by different network system during its trip.
@@ -603,7 +652,9 @@ public HttpResponse doGenerateNewToken(@AncestorInPath User u, @QueryParameter S
603652
@Restricted(NoExternalUse.class)
604653
public HttpResponse doAddFixedToken(@AncestorInPath User u,
605654
@QueryParameter String newTokenName,
606-
@QueryParameter String newTokenPlainValue) throws IOException {
655+
@QueryParameter String newTokenPlainValue,
656+
@QueryParameter String tokenExpiration,
657+
@QueryParameter String expirationDuration) throws IOException {
607658
if (!hasCurrentUserRightToGenerateNewToken(u)) {
608659
return HttpResponses.forbidden();
609660
}
@@ -615,13 +666,15 @@ public HttpResponse doAddFixedToken(@AncestorInPath User u,
615666
tokenName = newTokenName;
616667
}
617668

669+
LocalDate expirationDate = getExpirationDate(tokenExpiration, expirationDuration);
670+
618671
ApiTokenProperty p = u.getProperty(ApiTokenProperty.class);
619672
if (p == null) {
620673
p = forceNewInstance(u, false);
621674
u.addProperty(p);
622675
}
623676

624-
String tokenUuid = p.tokenStore.addFixedNewToken(tokenName, newTokenPlainValue);
677+
String tokenUuid = p.tokenStore.addFixedNewToken(tokenName, newTokenPlainValue, expirationDate);
625678
u.save();
626679

627680
Map<String, String> data = new HashMap<>();

core/src/main/java/jenkins/security/apitoken/ApiTokenStore.java

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import java.security.MessageDigest;
3636
import java.security.NoSuchAlgorithmException;
3737
import java.security.SecureRandom;
38+
import java.time.LocalDate;
3839
import java.util.ArrayList;
3940
import java.util.Collection;
4041
import java.util.Comparator;
@@ -43,6 +44,7 @@
4344
import java.util.List;
4445
import java.util.Locale;
4546
import java.util.Map;
47+
import java.util.Objects;
4648
import java.util.UUID;
4749
import java.util.logging.Level;
4850
import java.util.logging.Logger;
@@ -168,10 +170,18 @@ private void addLegacyToken(@NonNull Secret legacyToken, boolean migrationFromEx
168170
}
169171

170172
/**
171-
* Create a new token with the given name and return it id and secret value.
173+
* Create a new token with the given name and return its id and secret value.
172174
* Result meant to be sent / displayed and then discarded.
173175
*/
174176
public synchronized @NonNull TokenUuidAndPlainValue generateNewToken(@NonNull String name) {
177+
return generateNewToken(name, null);
178+
}
179+
180+
/**
181+
* Create a new token with the given name and expiration and return it id and secret value.
182+
* Result meant to be sent / displayed and then discarded.
183+
*/
184+
public synchronized @NonNull TokenUuidAndPlainValue generateNewToken(@NonNull String name, @Nullable LocalDate expirationDate) {
175185
// 16x8=128bit worth of randomness, using brute-force you need on average 2^127 tries (~10^37)
176186
byte[] random = new byte[16];
177187
RANDOM.nextBytes(random);
@@ -180,9 +190,9 @@ private void addLegacyToken(@NonNull Secret legacyToken, boolean migrationFromEx
180190
String tokenTheUserWillUse = HASH_VERSION + secretValue;
181191
assert tokenTheUserWillUse.length() == 2 + 32;
182192

183-
HashedToken token = prepareAndStoreToken(name, secretValue);
193+
HashedToken token = prepareAndStoreToken(name, secretValue, expirationDate);
184194

185-
return new TokenUuidAndPlainValue(token.uuid, tokenTheUserWillUse);
195+
return new TokenUuidAndPlainValue(token.uuid, tokenTheUserWillUse, expirationDate);
186196
}
187197

188198
private static final int VERSION_LENGTH = 2;
@@ -194,7 +204,7 @@ private void addLegacyToken(@NonNull Secret legacyToken, boolean migrationFromEx
194204
* it could be a good idea to generate a new token randomly and revoke this one.
195205
*/
196206
@SuppressFBWarnings(value = "UNSAFE_HASH_EQUALS", justification = "Comparison only validates version of the specified token")
197-
public synchronized @NonNull String addFixedNewToken(@NonNull String name, @NonNull String tokenPlainValue) {
207+
public synchronized @NonNull String addFixedNewToken(@NonNull String name, @NonNull String tokenPlainValue, @Nullable LocalDate expirationDate) {
198208
if (tokenPlainValue.length() != VERSION_LENGTH + HEX_CHAR_LENGTH) {
199209
LOGGER.log(Level.INFO, "addFixedNewToken, length received: {0}" + tokenPlainValue.length());
200210
throw new IllegalArgumentException("The token must consist of 2 characters for the version and 32 hex-characters for the secret");
@@ -211,16 +221,16 @@ private void addLegacyToken(@NonNull Secret legacyToken, boolean migrationFromEx
211221
throw new IllegalArgumentException("The secret part of the token must consist of 32 hex-characters");
212222
}
213223

214-
HashedToken token = prepareAndStoreToken(name, tokenPlainHexValue);
224+
HashedToken token = prepareAndStoreToken(name, tokenPlainHexValue, expirationDate);
215225

216226
return token.uuid;
217227
}
218228

219-
private @NonNull HashedToken prepareAndStoreToken(@NonNull String name, @NonNull String tokenPlainValue) {
229+
private @NonNull HashedToken prepareAndStoreToken(@NonNull String name, @NonNull String tokenPlainValue, LocalDate expirationDate) {
220230
String secretValueHashed = this.plainSecretToHashInHex(tokenPlainValue);
221231

222232
HashValue hashValue = new HashValue(HASH_VERSION, secretValueHashed);
223-
HashedToken token = HashedToken.buildNew(name, hashValue);
233+
HashedToken token = HashedToken.buildNew(name, hashValue, expirationDate);
224234

225235
this.addToken(token);
226236
return token;
@@ -369,6 +379,7 @@ public static class HashedToken implements Serializable {
369379
private String uuid;
370380
private String name;
371381
private Date creationDate;
382+
private LocalDate expirationDate;
372383

373384
private HashValue value;
374385

@@ -387,16 +398,21 @@ private void init() {
387398
}
388399
}
389400

390-
public static @NonNull HashedToken buildNew(@NonNull String name, @NonNull HashValue value) {
401+
public static @NonNull HashedToken buildNew(@NonNull String name, @NonNull HashValue value, LocalDate expirationDate) {
391402
HashedToken result = new HashedToken();
392403
result.name = name;
393404
result.creationDate = new Date();
394405

395406
result.value = value;
407+
result.expirationDate = expirationDate;
396408

397409
return result;
398410
}
399411

412+
public static @NonNull HashedToken buildNew(@NonNull String name, @NonNull HashValue value) {
413+
return buildNew(name, value, null);
414+
}
415+
400416
public static @NonNull HashedToken buildNewFromLegacy(@NonNull HashValue value, boolean migrationFromExistingLegacy) {
401417
HashedToken result = new HashedToken();
402418
result.name = Messages.ApiTokenProperty_LegacyTokenName();
@@ -426,8 +442,11 @@ public boolean match(byte[] hashedBytes) {
426442
return false;
427443
}
428444

445+
LocalDate now = LocalDate.now();
446+
LocalDate expiration = Objects.requireNonNullElseGet(expirationDate, LocalDate::now);
447+
boolean expired = expiration.isBefore(now);
429448
// String.equals() is not constant-time but this method is. No link between correctness and time spent
430-
return MessageDigest.isEqual(hashFromHex, hashedBytes);
449+
return MessageDigest.isEqual(hashFromHex, hashedBytes) && !expired;
431450
}
432451

433452
// used by Jelly view
@@ -440,6 +459,10 @@ public Date getCreationDate() {
440459
return creationDate;
441460
}
442461

462+
public LocalDate getExpirationDate() {
463+
return expirationDate;
464+
}
465+
443466
// used by Jelly view
444467
/**
445468
* Relevant only if the lastUseDate is not null

core/src/main/java/jenkins/security/apitoken/TokenUuidAndPlainValue.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
package jenkins.security.apitoken;
2626

27+
import java.time.LocalDate;
28+
import java.time.format.DateTimeFormatter;
2729
import org.kohsuke.accmod.Restricted;
2830
import org.kohsuke.accmod.restrictions.Beta;
2931

@@ -45,8 +47,17 @@ public class TokenUuidAndPlainValue {
4547
*/
4648
public final String plainValue;
4749

48-
public TokenUuidAndPlainValue(String tokenUuid, String plainValue) {
50+
51+
public final String expirationDate;
52+
53+
public TokenUuidAndPlainValue(String tokenUuid, String plainValue, LocalDate expirationDate) {
4954
this.tokenUuid = tokenUuid;
5055
this.plainValue = plainValue;
56+
if (expirationDate != null) {
57+
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E, L d u");
58+
this.expirationDate = formatter.format(expirationDate);
59+
} else {
60+
this.expirationDate = "Never";
61+
}
5162
}
5263
}

0 commit comments

Comments
 (0)