Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import jenkins.management.Badge;
import jenkins.model.Jenkins;
import jenkins.security.ApiTokenProperty;
import org.jenkinsci.Symbol;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
Expand Down Expand Up @@ -64,6 +66,14 @@
return UserProperty.allByCategoryClass(UserPropertyCategory.Security.class);
}

public Badge getBadge() {
ApiTokenProperty apiTokenProperty = getTargetUser().getProperty(ApiTokenProperty.class);
if (apiTokenProperty != null) {

Check warning on line 71 in core/src/main/java/hudson/model/userproperty/UserPropertyCategorySecurityAction.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 71 is only partially covered, one branch is missing
return apiTokenProperty.getBadge();
}
return null;

Check warning on line 74 in core/src/main/java/hudson/model/userproperty/UserPropertyCategorySecurityAction.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 74 is not covered by tests
}

/**
* Inject the outer class configuration page into the sidenav and the request routing of the user
*/
Expand Down
16 changes: 16 additions & 0 deletions core/src/main/java/jenkins/model/navigation/UserAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
import hudson.model.User;
import java.util.ArrayList;
import java.util.List;
import jenkins.management.Badge;
import jenkins.security.ApiTokenProperty;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

Expand Down Expand Up @@ -83,6 +85,20 @@
return User.current();
}

@Override
public Badge getBadge() {
User current = User.current();

if (User.current() == null) {

Check warning on line 92 in core/src/main/java/jenkins/model/navigation/UserAction.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 92 is only partially covered, one branch is missing
return null;

Check warning on line 93 in core/src/main/java/jenkins/model/navigation/UserAction.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 93 is not covered by tests
}
ApiTokenProperty apiTokenProperty = current.getProperty(ApiTokenProperty.class);
if (apiTokenProperty != null) {

Check warning on line 96 in core/src/main/java/jenkins/model/navigation/UserAction.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 96 is only partially covered, one branch is missing
return apiTokenProperty.getBadge();
}
return null;

Check warning on line 99 in core/src/main/java/jenkins/model/navigation/UserAction.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 99 is not covered by tests
}

@Override
public boolean isPrimaryAction() {
return true;
Expand Down
99 changes: 92 additions & 7 deletions core/src/main/java/jenkins/security/ApiTokenProperty.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@

import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import hudson.Extension;
import hudson.Functions;
import hudson.Util;
import hudson.model.Descriptor.FormException;
import hudson.model.User;
Expand All @@ -43,6 +45,7 @@
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
Expand All @@ -51,6 +54,7 @@
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import jenkins.management.Badge;
import jenkins.model.Jenkins;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenStats;
Expand Down Expand Up @@ -255,13 +259,26 @@
public final int useCounter;
public final Date lastUseDate;
public final long numDaysUse;
public final String expirationDate;
public final boolean expired;
public final boolean aboutToExpire;

public TokenInfoAndStats(@NonNull ApiTokenStore.HashedToken token, @NonNull ApiTokenStats.SingleTokenStats stats) {
this.uuid = token.getUuid();
this.name = token.getName();
this.creationDate = token.getCreationDate();
this.numDaysCreation = token.getNumDaysCreation();
this.isLegacy = token.isLegacy();
this.expired = token.isExpired();
this.aboutToExpire = token.isAboutToExpire();

LocalDate expirationDate = token.getExpirationDate();
if (expirationDate == null) {

Check warning on line 276 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 276 is only partially covered, one branch is missing
this.expirationDate = "never";
Copy link
Member

@lemeurherve lemeurherve Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be a breaking change maybe for later, but how about having a short-lived token generated by default instead of an eternal one to prevent mistakes, and to ensure eternal token creation is a conscencious and deliberate choice?

(EDIT: The line I put this comment on isn't the proper one, sorry)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the UI the default is set to 30 days.

} else {
this.expirationDate = expirationDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Functions.getCurrentLocale()));

Check warning on line 280 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 279-280 are not covered by tests
}

this.useCounter = stats.getUseCounter();
this.lastUseDate = stats.getLastUseDate();
Expand Down Expand Up @@ -424,15 +441,27 @@
// essentially meant for scripting
@Restricted(Beta.class)
public @NonNull String addFixedNewToken(@NonNull String name, @NonNull String tokenPlainValue) throws IOException {
String tokenUuid = this.tokenStore.addFixedNewToken(name, tokenPlainValue);
return addFixedNewToken(name, tokenPlainValue, null);
}

// essentially meant for scripting
@Restricted(Beta.class)
public @NonNull String addFixedNewToken(@NonNull String name, @NonNull String tokenPlainValue, @Nullable LocalDate expirationDate) throws IOException {
String tokenUuid = this.tokenStore.addFixedNewToken(name, tokenPlainValue, expirationDate);
user.save();
return tokenUuid;
}

// essentially meant for scripting
@Restricted(Beta.class)
public @NonNull TokenUuidAndPlainValue generateNewToken(@NonNull String name) throws IOException {
TokenUuidAndPlainValue tokenUuidAndPlainValue = tokenStore.generateNewToken(name);
return generateNewToken(name, null);
}

// essentially meant for scripting
@Restricted(Beta.class)
public @NonNull TokenUuidAndPlainValue generateNewToken(@NonNull String name, @Nullable LocalDate expirationDate) throws IOException {
TokenUuidAndPlainValue tokenUuidAndPlainValue = tokenStore.generateNewToken(name, expirationDate);
user.save();
return tokenUuidAndPlainValue;
}
Expand Down Expand Up @@ -535,7 +564,7 @@
}

/**
* @deprecated use {@link #doGenerateNewToken(User, String)} instead
* @deprecated use {@link #doGenerateNewToken(User, String, String, String)} instead
*/
@Deprecated
@RequirePOST
Expand Down Expand Up @@ -567,7 +596,8 @@
}

@RequirePOST
public HttpResponse doGenerateNewToken(@AncestorInPath User u, @QueryParameter String newTokenName) throws IOException {
public HttpResponse doGenerateNewToken(@AncestorInPath User u, @QueryParameter String newTokenName,
@QueryParameter String tokenExpiration, @QueryParameter String expirationDuration) throws IOException {
if (!hasCurrentUserRightToGenerateNewToken(u)) {
return HttpResponses.forbidden();
}
Expand All @@ -579,49 +609,83 @@
tokenName = newTokenName;
}

LocalDate expirationDate = getExpirationDate(tokenExpiration, expirationDuration);

ApiTokenProperty p = u.getProperty(ApiTokenProperty.class);
if (p == null) {
p = forceNewInstance(u, false);
u.addProperty(p);
}

TokenUuidAndPlainValue tokenUuidAndPlainValue = p.generateNewToken(tokenName);
TokenUuidAndPlainValue tokenUuidAndPlainValue = p.generateNewToken(tokenName, expirationDate);
String expirationDateString = "never";
if (expirationDate != null) {

Check warning on line 622 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 622 is only partially covered, one branch is missing
expirationDateString = expirationDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Functions.getCurrentLocale()));

Check warning on line 624 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 623-624 are not covered by tests
}

Map<String, String> data = new HashMap<>();
data.put("tokenUuid", tokenUuidAndPlainValue.tokenUuid);
data.put("tokenName", tokenName);
data.put("tokenValue", tokenUuidAndPlainValue.plainValue);
data.put("expirationDate", expirationDateString);
return HttpResponses.okJSON(data);
}

private LocalDate getExpirationDate(String tokenExpiration, String expirationDuration) {
if (expirationDuration == null) {

Check warning on line 636 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 636 is only partially covered, one branch is missing
expirationDuration = "";
}
expirationDuration = expirationDuration.trim();

return switch (expirationDuration) {

Check warning on line 641 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 641 is only partially covered, 2 branches are missing
case "", "never" -> {
yield null;
}
case "custom" -> {
yield LocalDate.parse(tokenExpiration);
}
default -> {
LocalDate now = LocalDate.now();
int days = Integer.parseInt(expirationDuration);
yield now.plusDays(days);
}
};

}

/**
* This method is dangerous and should not be used without caution.
* The token passed here could have been tracked by different network system during its trip.
* It is recommended to revoke this token after the generation of a new one.
*/
@RequirePOST
@Restricted(NoExternalUse.class)
public HttpResponse doAddFixedToken(@AncestorInPath User u,
@QueryParameter String newTokenName,
@QueryParameter String newTokenPlainValue) throws IOException {
@QueryParameter String newTokenPlainValue,
@QueryParameter String tokenExpiration,
@QueryParameter String expirationDuration) throws IOException {
if (!hasCurrentUserRightToGenerateNewToken(u)) {
return HttpResponses.forbidden();
}

final String tokenName;
if (newTokenName == null || newTokenName.isBlank()) {
tokenName = String.format("Token created on %s", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now()));
} else {
tokenName = newTokenName;
}

LocalDate expirationDate = getExpirationDate(tokenExpiration, expirationDuration);

ApiTokenProperty p = u.getProperty(ApiTokenProperty.class);
if (p == null) {
p = forceNewInstance(u, false);
u.addProperty(p);
}

String tokenUuid = p.tokenStore.addFixedNewToken(tokenName, newTokenPlainValue);
String tokenUuid = p.tokenStore.addFixedNewToken(tokenName, newTokenPlainValue, expirationDate);

Check warning on line 688 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 646-688 are not covered by tests
u.save();

Map<String, String> data = new HashMap<>();
Expand Down Expand Up @@ -739,4 +803,25 @@
@Deprecated
@Restricted(NoExternalUse.class)
public static final HMACConfidentialKey API_KEY_SEED = new HMACConfidentialKey(ApiTokenProperty.class, "seed", 16);

@Restricted(NoExternalUse.class)
public Badge getBadge() {
long expiringTokenCount = getTokenList().stream().filter(t -> t.aboutToExpire && !t.expired).count();

Check warning on line 809 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 809 is only partially covered, 3 branches are missing
long expiredTokenCount = getTokenList().stream().filter(t -> t.expired).count();
StringBuilder tooltip = new StringBuilder();
if (expiringTokenCount > 0) {

Check warning on line 812 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 812 is only partially covered, one branch is missing
tooltip.append(jenkins.model.navigation.Messages.UserAction_aboutToExpireTokens(expiringTokenCount));

Check warning on line 813 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 813 is not covered by tests
}
if (expiredTokenCount > 0) {

Check warning on line 815 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 815 is only partially covered, one branch is missing
if (expiringTokenCount > 0) {
tooltip.append("\n");
}
tooltip.append(jenkins.model.navigation.Messages.UserAction_expiredTokens(expiredTokenCount));

Check warning on line 819 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 816-819 are not covered by tests
}
if (expiredTokenCount + expiringTokenCount > 0) {

Check warning on line 821 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 821 is only partially covered, one branch is missing
return new Badge(Long.toString(expiringTokenCount + expiredTokenCount), tooltip.toString(), Badge.Severity.WARNING);

Check warning on line 822 in core/src/main/java/jenkins/security/ApiTokenProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 822 is not covered by tests
} else {
return null;
}
}
}
Loading
Loading