未验证 提交 fd1ec1d1 编写于 作者: D Daniel Beck 提交者: GitHub

Merge pull request #3271 from Wadeck/JENKINS-32442-32776_HASHED_TOKEN

[JENKINS-32442][JENKINS-32776] New API Token system
......@@ -764,6 +764,16 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
public @Override String toString() {
return fullName;
}
/**
* Returns the folder that store all the user information
* Useful for plugins to save a user-specific file aside the config.xml
*
* @since TODO
*/
public File getUserFolder(){
return getUserFolderFor(this.id);
}
/**
* The file we save our configuration.
......@@ -773,7 +783,11 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
}
private static final File getConfigFileFor(String id) {
return new File(getRootDir(), idStrategy().filenameOf(id) +"/config.xml");
return new File(getUserFolderFor(id), "config.xml");
}
private static File getUserFolderFor(String id){
return new File(getRootDir(), idStrategy().filenameOf(id));
}
private static File getUnsanitizedLegacyConfigFileFor(String id) {
......
......@@ -11,6 +11,7 @@ import java.util.logging.Logger;
import javax.inject.Provider;
import javax.servlet.http.HttpSession;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import org.apache.commons.io.FileUtils;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
......@@ -56,6 +57,8 @@ public class UpgradeWizard extends InstallState {
@Override
public void initializeState() {
applyForcedChanges();
// Initializing this state is directly related to
// running the detached plugin checks, these should be consolidated somehow
updateUpToDate();
......@@ -68,6 +71,21 @@ public class UpgradeWizard extends InstallState {
}
}
/**
* Put here the different changes that are enforced after an update.
*/
private void applyForcedChanges(){
// Disable the legacy system of API Token only if the new system was not installed
// in such case it means there was already an upgrade before
// and potentially the admin has re-enabled the features
ApiTokenPropertyConfiguration apiTokenPropertyConfiguration = ApiTokenPropertyConfiguration.get();
if(!apiTokenPropertyConfiguration.hasExistingConfigFile()){
LOGGER.log(Level.INFO, "New API token system configured with insecure options to keep legacy behavior");
apiTokenPropertyConfiguration.setCreationOfLegacyTokenEnabled(false);
apiTokenPropertyConfiguration.setTokenGenerationOnCreationEnabled(false);
}
}
@Override
public boolean isSetupComplete() {
return !isDue();
......
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import hudson.Extension;
import jenkins.model.GlobalConfiguration;
import jenkins.model.GlobalConfigurationCategory;
import org.jenkinsci.Symbol;
/**
* Configuration for the new token generation when a user is created
*
* @since TODO
*/
@Extension
@Symbol("apiToken")
public class ApiTokenPropertyConfiguration extends GlobalConfiguration {
/**
* When a user is created, this property determine if we create a legacy token for the user or not
* For security reason, we do not recommend to enable this but we let that open to ease upgrade.
*/
private boolean tokenGenerationOnCreationEnabled = false;
/**
* When a user has a legacy token, this property determine if the user can request a new legacy token or not
* For security reason, we do not recommend to enable this but we let that open to ease upgrade.
*/
private boolean creationOfLegacyTokenEnabled = false;
/**
* Each time an API Token is used, its usage counter is incremented and the last usage date is updated.
* You can disable this feature using this property.
*/
private boolean usageStatisticsEnabled = true;
public static ApiTokenPropertyConfiguration get() {
return GlobalConfiguration.all().get(ApiTokenPropertyConfiguration.class);
}
public ApiTokenPropertyConfiguration() {
load();
}
public boolean hasExistingConfigFile(){
return getConfigFile().exists();
}
public boolean isTokenGenerationOnCreationEnabled() {
return tokenGenerationOnCreationEnabled;
}
public void setTokenGenerationOnCreationEnabled(boolean tokenGenerationOnCreationEnabled) {
this.tokenGenerationOnCreationEnabled = tokenGenerationOnCreationEnabled;
save();
}
public boolean isCreationOfLegacyTokenEnabled() {
return creationOfLegacyTokenEnabled;
}
public void setCreationOfLegacyTokenEnabled(boolean creationOfLegacyTokenEnabled) {
this.creationOfLegacyTokenEnabled = creationOfLegacyTokenEnabled;
save();
}
public boolean isUsageStatisticsEnabled() {
return usageStatisticsEnabled;
}
public void setUsageStatisticsEnabled(boolean usageStatisticsEnabled) {
this.usageStatisticsEnabled = usageStatisticsEnabled;
save();
}
@Override
public GlobalConfigurationCategory getCategory() {
return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class);
}
}
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import hudson.Extension;
import hudson.model.AdministrativeMonitor;
import hudson.util.HttpResponses;
import org.jenkinsci.Symbol;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.interceptor.RequirePOST;
import java.io.IOException;
/**
* Monitor that the API Token are not generated by default without the user interaction.
*/
@Extension
@Symbol("apiTokenLegacyAutoGeneration")
@Restricted(NoExternalUse.class)
public class ApiTokenPropertyDisabledDefaultAdministrativeMonitor extends AdministrativeMonitor {
@Override
public String getDisplayName() {
return Messages.ApiTokenPropertyDisabledDefaultAdministrativeMonitor_displayName();
}
@Override
public boolean isActivated() {
return ApiTokenPropertyConfiguration.get().isTokenGenerationOnCreationEnabled();
}
@RequirePOST
public HttpResponse doAct(@QueryParameter String no) throws IOException {
if (no == null) {
ApiTokenPropertyConfiguration.get().setTokenGenerationOnCreationEnabled(false);
} else {
disable(true);
}
return HttpResponses.redirectViaContextPath("manage");
}
}
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import hudson.Extension;
import hudson.model.AdministrativeMonitor;
import hudson.util.HttpResponses;
import org.jenkinsci.Symbol;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.interceptor.RequirePOST;
import java.io.IOException;
/**
* Monitor that the API Token cannot be created for a user without existing legacy token
*/
@Extension
@Symbol("apiTokenNewLegacyWithoutExisting")
@Restricted(NoExternalUse.class)
public class ApiTokenPropertyEnabledNewLegacyAdministrativeMonitor extends AdministrativeMonitor {
@Override
public String getDisplayName() {
return Messages.ApiTokenPropertyEnabledNewLegacyAdministrativeMonitor_displayName();
}
@Override
public boolean isActivated() {
return ApiTokenPropertyConfiguration.get().isCreationOfLegacyTokenEnabled();
}
@RequirePOST
public HttpResponse doAct(@QueryParameter String no) throws IOException {
if (no == null) {
ApiTokenPropertyConfiguration.get().setCreationOfLegacyTokenEnabled(false);
} else {
disable(true);
}
return HttpResponses.redirectViaContextPath("manage");
}
}
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import hudson.BulkChange;
import hudson.XmlFile;
import hudson.model.Saveable;
import hudson.model.listeners.SaveableListener;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
@Restricted(NoExternalUse.class)
public class ApiTokenStats implements Saveable {
private static final Logger LOGGER = Logger.getLogger(ApiTokenStats.class.getName());
/**
* Normally a user will not have more 2-3 tokens at a time,
* so there is no need to store a map here
*/
private List<SingleTokenStats> tokenStats;
private transient File parent;
public ApiTokenStats() {
this.init();
}
private ApiTokenStats readResolve() {
this.init();
return this;
}
private void init() {
if (this.tokenStats == null) {
this.tokenStats = new ArrayList<>();
} else {
keepLastUpdatedUnique();
}
}
/**
* In case of duplicate entries, we keep only the last updated element
*/
private void keepLastUpdatedUnique() {
Map<String, SingleTokenStats> temp = new HashMap<>();
this.tokenStats.forEach(candidate -> {
SingleTokenStats current = temp.get(candidate.tokenUuid);
if (current == null) {
temp.put(candidate.tokenUuid, candidate);
} else {
int comparison = SingleTokenStats.COMP_BY_LAST_USE_THEN_COUNTER.compare(current, candidate);
if (comparison < 0) {
// candidate was updated more recently (or has a bigger counter in case of perfectly equivalent dates)
temp.put(candidate.tokenUuid, candidate);
}
}
});
this.tokenStats = new ArrayList<>(temp.values());
}
void setParent(@Nonnull File parent) {
this.parent = parent;
}
private boolean areStatsDisabled(){
return !ApiTokenPropertyConfiguration.get().isUsageStatisticsEnabled();
}
/**
* Will trigger the save if there is some modification
*/
public synchronized void removeId(@Nonnull String tokenUuid) {
if(areStatsDisabled()){
return;
}
boolean tokenRemoved = tokenStats.removeIf(s -> s.tokenUuid.equals(tokenUuid));
if (tokenRemoved) {
save();
}
}
/**
* Will trigger the save
*/
public synchronized @Nonnull SingleTokenStats updateUsageForId(@Nonnull String tokenUuid) {
if(areStatsDisabled()){
return new SingleTokenStats(tokenUuid);
}
SingleTokenStats stats = findById(tokenUuid)
.orElseGet(() -> {
SingleTokenStats result = new SingleTokenStats(tokenUuid);
tokenStats.add(result);
return result;
});
stats.notifyUse();
save();
return stats;
}
public synchronized @Nonnull SingleTokenStats findTokenStatsById(@Nonnull String tokenUuid) {
if(areStatsDisabled()){
return new SingleTokenStats(tokenUuid);
}
// if we create a new empty stats object, no need to add it to the list
return findById(tokenUuid)
.orElse(new SingleTokenStats(tokenUuid));
}
private @Nonnull Optional<SingleTokenStats> findById(@Nonnull String tokenUuid) {
return tokenStats.stream()
.filter(s -> s.tokenUuid.equals(tokenUuid))
.findFirst();
}
/**
* Saves the configuration info to the disk.
*/
@Override
public synchronized void save() {
if(areStatsDisabled()){
return;
}
if (BulkChange.contains(this))
return;
XmlFile configFile = getConfigFile(parent);
try {
configFile.write(this);
SaveableListener.fireOnChange(this, configFile);
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Failed to save " + configFile, e);
}
}
/**
* Loads the data from the disk into the new object.
* <p>
* If the file is not present, a fresh new instance is created.
*/
public static @Nonnull ApiTokenStats load(@Nonnull File parent) {
// even if we are not using statistics, we load the existing one in case the configuration
// is enabled afterwards to avoid erasing data
XmlFile file = getConfigFile(parent);
ApiTokenStats apiTokenStats;
if (file.exists()) {
try {
apiTokenStats = (ApiTokenStats) file.unmarshal(ApiTokenStats.class);
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Failed to load " + file, e);
apiTokenStats = new ApiTokenStats();
}
} else {
apiTokenStats = new ApiTokenStats();
}
apiTokenStats.setParent(parent);
return apiTokenStats;
}
protected static XmlFile getConfigFile(File parent) {
return new XmlFile(new File(parent, "apiTokenStats.xml"));
}
public static class SingleTokenStats {
private static Comparator<SingleTokenStats> COMP_BY_LAST_USE_THEN_COUNTER =
Comparator.comparing(SingleTokenStats::getLastUseDate, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(SingleTokenStats::getUseCounter);
private final String tokenUuid;
private Date lastUseDate;
private Integer useCounter;
private SingleTokenStats(String tokenUuid) {
this.tokenUuid = tokenUuid;
}
private SingleTokenStats readResolve() {
if (this.useCounter != null) {
// to avoid negative numbers to be injected
this.useCounter = Math.max(0, this.useCounter);
}
return this;
}
private void notifyUse() {
this.useCounter = useCounter == null ? 1 : useCounter + 1;
this.lastUseDate = new Date();
}
public String getTokenUuid() {
return tokenUuid;
}
// used by Jelly view
public int getUseCounter() {
return useCounter == null ? 0 : useCounter;
}
// used by Jelly view
public Date getLastUseDate() {
return lastUseDate;
}
// used by Jelly view
/**
* Return the number of days since the last usage
* Relevant only if the lastUseDate is not null
*/
public long getNumDaysUse() {
return lastUseDate == null ? 0 : computeDeltaDays(lastUseDate.toInstant(), Instant.now());
}
private long computeDeltaDays(Instant a, Instant b) {
long deltaDays = ChronoUnit.DAYS.between(a, b);
deltaDays = Math.max(0, deltaDays);
return deltaDays;
}
}
}
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Util;
import hudson.util.Secret;
import jenkins.security.Messages;
import net.sf.json.JSONObject;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
@Restricted(NoExternalUse.class)
public class ApiTokenStore {
private static final Logger LOGGER = Logger.getLogger(ApiTokenStore.class.getName());
private static final SecureRandom RANDOM = new SecureRandom();
private static final Comparator<HashedToken> SORT_BY_LOWERCASED_NAME =
Comparator.comparing(hashedToken -> hashedToken.getName().toLowerCase(Locale.ENGLISH));
private static final int TOKEN_LENGTH_V2 = 34;
/** two hex characters, avoid starting with 0 to avoid troubles */
private static final String LEGACY_VERSION = "10";
private static final String HASH_VERSION = "11";
private static final String HASH_ALGORITHM = "SHA-256";
private List<HashedToken> tokenList;
public ApiTokenStore() {
this.init();
}
private ApiTokenStore readResolve() {
this.init();
return this;
}
private void init() {
if (this.tokenList == null) {
this.tokenList = new ArrayList<>();
}
}
@SuppressFBWarnings("NP_NONNULL_RETURN_VIOLATION")
public synchronized @Nonnull Collection<HashedToken> getTokenListSortedByName() {
return tokenList.stream()
.sorted(SORT_BY_LOWERCASED_NAME)
.collect(Collectors.toList());
}
private void addToken(HashedToken token) {
this.tokenList.add(token);
}
/**
* Defensive approach to avoid involuntary change since the UUIDs are generated at startup only for UI
* and so between restart they change
*/
public synchronized void reconfigure(@Nonnull Map<String, JSONObject> tokenStoreDataMap) {
tokenList.forEach(hashedToken -> {
JSONObject receivedTokenData = tokenStoreDataMap.get(hashedToken.uuid);
if (receivedTokenData == null) {
LOGGER.log(Level.INFO, "No token received for {0}", hashedToken.uuid);
return;
}
String name = receivedTokenData.getString("tokenName");
if (StringUtils.isBlank(name)) {
LOGGER.log(Level.INFO, "Empty name received for {0}, we do not care about it", hashedToken.uuid);
return;
}
hashedToken.setName(name);
});
}
/**
* Remove the legacy token present and generate a new one using the given secret.
*/
public synchronized void regenerateTokenFromLegacy(@Nonnull Secret newLegacyApiToken) {
deleteAllLegacyAndGenerateNewOne(newLegacyApiToken);
}
/**
* Same as {@link #regenerateTokenFromLegacy(Secret)} but only applied if there is an existing legacy token.
* <p>
* Otherwise, no effect.
*/
public synchronized void regenerateTokenFromLegacyIfRequired(@Nonnull Secret newLegacyApiToken) {
if(tokenList.stream().noneMatch(HashedToken::isLegacy)){
deleteAllLegacyAndGenerateNewOne(newLegacyApiToken);
}
}
private void deleteAllLegacyAndGenerateNewOne(@Nonnull Secret newLegacyApiToken) {
deleteAllLegacyTokens();
addLegacyToken(newLegacyApiToken);
}
private void deleteAllLegacyTokens() {
// normally there is only one, but just in case
tokenList.removeIf(HashedToken::isLegacy);
}
private void addLegacyToken(@Nonnull Secret legacyToken) {
String tokenUserUseNormally = Util.getDigestOf(legacyToken.getPlainText());
String secretValueHashed = this.plainSecretToHashInHex(tokenUserUseNormally);
HashValue hashValue = new HashValue(LEGACY_VERSION, secretValueHashed);
HashedToken token = HashedToken.buildNew(Messages.ApiTokenProperty_LegacyTokenName(), hashValue);
this.addToken(token);
}
/**
* @return {@code null} iff there is no legacy token in the store, otherwise the legacy token is returned
*/
public synchronized @Nullable HashedToken getLegacyToken(){
return tokenList.stream()
.filter(HashedToken::isLegacy)
.findFirst()
.orElse(null);
}
/**
* Create a new token with the given name and return it id and secret value.
* Result meant to be sent / displayed and then discarded.
*/
public synchronized @Nonnull TokenUuidAndPlainValue generateNewToken(@Nonnull String name) {
// 16x8=128bit worth of randomness, using brute-force you need on average 2^127 tries (~10^37)
byte[] random = new byte[16];
RANDOM.nextBytes(random);
String secretValue = Util.toHexString(random);
String tokenTheUserWillUse = HASH_VERSION + secretValue;
assert tokenTheUserWillUse.length() == 2 + 32;
String secretValueHashed = this.plainSecretToHashInHex(secretValue);
HashValue hashValue = new HashValue(HASH_VERSION, secretValueHashed);
HashedToken token = HashedToken.buildNew(name, hashValue);
this.addToken(token);
return new TokenUuidAndPlainValue(token.uuid, tokenTheUserWillUse);
}
@SuppressFBWarnings("NP_NONNULL_RETURN_VIOLATION")
private @Nonnull String plainSecretToHashInHex(@Nonnull String secretValueInPlainText) {
byte[] hashBytes = plainSecretToHashBytes(secretValueInPlainText);
return Util.toHexString(hashBytes);
}
private @Nonnull byte[] plainSecretToHashBytes(@Nonnull String secretValueInPlainText) {
// ascii is sufficient for hex-format
return hashedBytes(secretValueInPlainText.getBytes(StandardCharsets.US_ASCII));
}
private @Nonnull 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");
}
return digest.digest(tokenBytes);
}
/**
* Search in the store if there is a token with the same secret as the one given
* @return {@code null} iff there is no matching token
*/
public synchronized @CheckForNull HashedToken findMatchingToken(@Nonnull String token) {
String plainToken;
if (isLegacyToken(token)) {
plainToken = token;
} else {
plainToken = getHashOfToken(token);
}
return searchMatch(plainToken);
}
/**
* Determine if the given token was generated by the legacy system or the new one
*/
private boolean isLegacyToken(@Nonnull String token) {
return token.length() != TOKEN_LENGTH_V2;
}
/**
* Retrieve the hash part of the token
* @param token assumed the token is not a legacy one and represent the full token (version + hash)
* @return the hash part
*/
private @Nonnull String getHashOfToken(@Nonnull String token) {
/*
* Structure of the token:
*
* [2: version][32: real token]
* ------------^^^^^^^^^^^^^^^^
*/
return token.substring(2);
}
/**
* Search in the store if there is a matching token that has the same secret.
* @return {@code null} iff there is no matching token
*/
private @CheckForNull HashedToken searchMatch(@Nonnull String plainSecret) {
byte[] hashedBytes = plainSecretToHashBytes(plainSecret);
for (HashedToken token : tokenList) {
if (token.match(hashedBytes)) {
return token;
}
}
return null;
}
/**
* Remove a token given its identifier. Effectively make it unusable for future connection.
*
* @param tokenUuid The identifier of the token, could be retrieved directly from the {@link HashedToken#getUuid()}
* @return the revoked token corresponding to the given {@code tokenUuid} if one was found, otherwise {@code null}
*/
public synchronized @CheckForNull HashedToken revokeToken(@Nonnull String tokenUuid) {
for (Iterator<HashedToken> iterator = tokenList.iterator(); iterator.hasNext(); ) {
HashedToken token = iterator.next();
if (token.uuid.equals(tokenUuid)) {
iterator.remove();
return token;
}
}
return null;
}
/**
* Given a token identifier and a name, the system will try to find a corresponding token and rename it
* @return {@code true} iff the token was found and the rename was successful
*/
public synchronized boolean renameToken(@Nonnull String tokenUuid, @Nonnull String newName) {
for (HashedToken token : tokenList) {
if (token.uuid.equals(tokenUuid)) {
token.rename(newName);
return true;
}
}
LOGGER.log(Level.FINER, "The target token for rename does not exist, for uuid = {0}, with desired name = {1}", new Object[]{tokenUuid, newName});
return false;
}
@Immutable
private static class HashValue {
/**
* Allow to distinguish tokens from different versions easily to adapt the logic
*/
private final String version;
/**
* Only confidential information in this class. It's a SHA-256 hash stored in hex format
*/
private final String hash;
private HashValue(String version, String hash) {
this.version = version;
this.hash = hash;
}
}
/**
* Contains information about the token and the secret value.
* It should not be stored as is, but just displayed once to the user and then forget about it.
*/
@Immutable
public static class TokenUuidAndPlainValue {
/**
* The token identifier to allow manipulation of the token
*/
public final String tokenUuid;
/**
* Confidential information, must not be stored.<p>
* It's meant to be send only one to the user and then only store the hash of this value.
*/
public final String plainValue;
private TokenUuidAndPlainValue(String tokenUuid, String plainValue) {
this.tokenUuid = tokenUuid;
this.plainValue = plainValue;
}
}
public static class HashedToken {
// allow us to rename the token and link the statistics
private String uuid;
private String name;
private Date creationDate;
private HashValue value;
private HashedToken() {
this.init();
}
private HashedToken readResolve() {
this.init();
return this;
}
private void init() {
if(this.uuid == null){
this.uuid = UUID.randomUUID().toString();
}
}
public static @Nonnull HashedToken buildNew(@Nonnull String name, @Nonnull HashValue value) {
HashedToken result = new HashedToken();
result.name = name;
result.creationDate = new Date();
result.value = value;
return result;
}
public void rename(String newName) {
this.name = newName;
}
public boolean match(byte[] hashedBytes) {
byte[] hashFromHex;
try {
hashFromHex = Util.fromHexString(value.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;
}
// String.equals() is not constant-time but this method is. No link between correctness and time spent
return MessageDigest.isEqual(hashFromHex, hashedBytes);
}
// used by Jelly view
public String getName() {
return name;
}
// used by Jelly view
public Date getCreationDate() {
return creationDate;
}
// used by Jelly view
/**
* Relevant only if the lastUseDate is not null
*/
public long getNumDaysCreation() {
return creationDate == null ? 0 : computeDeltaDays(creationDate.toInstant(), Instant.now());
}
// used by Jelly view
public String getUuid() {
return this.uuid;
}
private long computeDeltaDays(Instant a, Instant b) {
long deltaDays = ChronoUnit.DAYS.between(a, b);
deltaDays = Math.max(0, deltaDays);
return deltaDays;
}
public boolean isLegacy() {
return this.value.version.equals(LEGACY_VERSION);
}
public void setName(String name) {
this.name = name;
}
}
}
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import hudson.Extension;
import hudson.model.AdministrativeMonitor;
import hudson.model.User;
import hudson.node_monitors.AbstractAsyncNodeMonitorDescriptor;
import hudson.util.HttpResponses;
import jenkins.security.ApiTokenProperty;
import org.jenkinsci.Symbol;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpRedirect;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.interceptor.RequirePOST;
import org.kohsuke.stapler.json.JsonBody;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.util.Date;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
/**
* Monitor the list of users that still have legacy token
*/
@Extension
@Symbol("legacyApiTokenUsage")
@Restricted(NoExternalUse.class)
public class LegacyApiTokenAdministrativeMonitor extends AdministrativeMonitor {
private static final Logger LOGGER = Logger.getLogger(AbstractAsyncNodeMonitorDescriptor.class.getName());
public LegacyApiTokenAdministrativeMonitor() {
super("legacyApiToken");
}
@Override
public String getDisplayName() {
return Messages.LegacyApiTokenAdministrativeMonitor_displayName();
}
@Override
public boolean isActivated() {
return User.getAll().stream()
.anyMatch(user -> {
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
return (apiTokenProperty != null && apiTokenProperty.hasLegacyToken());
});
}
public HttpResponse doIndex() throws IOException {
return new HttpRedirect("manage");
}
// used by Jelly view
@Restricted(NoExternalUse.class)
public List<User> getImpactedUserList() {
return User.getAll().stream()
.filter(user -> {
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
return (apiTokenProperty != null && apiTokenProperty.hasLegacyToken());
})
.collect(Collectors.toList());
}
// used by Jelly view
@Restricted(NoExternalUse.class)
public @Nullable ApiTokenStore.HashedToken getLegacyTokenOf(@Nonnull User user) {
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
ApiTokenStore.HashedToken legacyToken = apiTokenProperty.getTokenStore().getLegacyToken();
return legacyToken;
}
// used by Jelly view
@Restricted(NoExternalUse.class)
public @Nullable ApiTokenProperty.TokenInfoAndStats getLegacyStatsOf(@Nonnull User user, @Nullable ApiTokenStore.HashedToken legacyToken) {
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
if(legacyToken != null){
ApiTokenStats.SingleTokenStats legacyStats = apiTokenProperty.getTokenStats().findTokenStatsById(legacyToken.getUuid());
ApiTokenProperty.TokenInfoAndStats tokenInfoAndStats = new ApiTokenProperty.TokenInfoAndStats(legacyToken, legacyStats);
return tokenInfoAndStats;
}
// in case the legacy token was revoked during the request
return null;
}
/**
* Determine if the user has at least one "new" token that was created after the last use of the legacy token
*/
// used by Jelly view
@Restricted(NoExternalUse.class)
public boolean hasFreshToken(@Nonnull User user, @Nullable ApiTokenProperty.TokenInfoAndStats legacyStats) {
if(legacyStats == null){
return false;
}
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
return apiTokenProperty.getTokenList().stream()
.filter(token -> !token.isLegacy)
.anyMatch(token -> {
Date creationDate = token.creationDate;
Date lastUseDate = legacyStats.lastUseDate;
if (lastUseDate == null) {
lastUseDate = legacyStats.creationDate;
}
return creationDate != null && lastUseDate != null && creationDate.after(lastUseDate);
});
}
/**
* Determine if the user has at least one "new" token that was used after the last use of the legacy token
*/
// used by Jelly view
@Restricted(NoExternalUse.class)
public boolean hasMoreRecentlyUsedToken(@Nonnull User user, @Nullable ApiTokenProperty.TokenInfoAndStats legacyStats) {
if(legacyStats == null){
return false;
}
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
return apiTokenProperty.getTokenList().stream()
.filter(token -> !token.isLegacy)
.anyMatch(token -> {
Date currentLastUseDate = token.lastUseDate;
Date legacyLastUseDate = legacyStats.lastUseDate;
if (legacyLastUseDate == null) {
legacyLastUseDate = legacyStats.creationDate;
}
return currentLastUseDate != null && legacyLastUseDate != null && currentLastUseDate.after(legacyLastUseDate);
});
}
@RequirePOST
public HttpResponse doRevokeAllSelected(@JsonBody RevokeAllSelectedModel content) throws IOException {
for (RevokeAllSelectedUserAndUuid value : content.values) {
User user = User.getById(value.userId, false);
if (user == null) {
LOGGER.log(Level.INFO, "User not found id={0}", value.userId);
} else {
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
if(apiTokenProperty == null){
LOGGER.log(Level.INFO, "User without apiTokenProperty found id={0}", value.userId);
}else{
ApiTokenStore.HashedToken revokedToken = apiTokenProperty.getTokenStore().revokeToken(value.uuid);
if(revokedToken == null){
LOGGER.log(Level.INFO, "User without selected token id={0}, tokenUuid={1}", new Object[]{value.userId, value.uuid});
}else{
apiTokenProperty.deleteApiToken();
user.save();
LOGGER.log(Level.INFO, "Revocation success for user id={0}, tokenUuid={1}", new Object[]{value.userId, value.uuid});
}
}
}
}
return HttpResponses.ok();
}
@Restricted(NoExternalUse.class)
public static final class RevokeAllSelectedModel {
public RevokeAllSelectedUserAndUuid[] values;
}
@Restricted(NoExternalUse.class)
public static final class RevokeAllSelectedUserAndUuid {
public String userId;
public String uuid;
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!--
The MIT License
Copyright 2018 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.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:f="/lib/form" xmlns:l="/lib/layout" xmlns:i="jelly:fmt">
<st:adjunct includes="jenkins.security.ApiTokenProperty.resources"/>
<f:entry title="${%CurrentTokens(instance.tokenList.size())}" help="${descriptor.getHelpFile('tokenStore')}">
<!-- ignore the whole div to prevent the "Add new token" button to trigger the confirm -->
<div class="token-list ignore-dirty-panel">
<j:set var="isStatisticsEnabled" value="${descriptor.isStatisticsEnabled()}" />
<j:set var="tokenList" value="${instance.tokenList}" />
<div class="token-list-item token-list-empty-item ${tokenList == null || tokenList.isEmpty() ? '' : 'hidden-message'}">
<div class="list-empty-message">${%NoTokenYet}</div>
</div>
<f:repeatable name="tokenStore" var="token" items="${tokenList}" minimum="0" add="${%AddNewToken}"
noAddButton="${!descriptor.hasCurrentUserRightToGenerateNewToken(it)}">
<j:choose>
<j:when test="${token!=null}">
<j:set var="legacyClazz" value="" />
<j:if test="${token.isLegacy}">
<j:set var="legacyClazz" value="legacy-token" />
</j:if>
<!-- force the confirm in case those inputs are changed, to override the parent ignore, for existing token -->
<div class="token-list-item token-list-existing-item force-dirty-panel ${legacyClazz}">
<input type="hidden" class="token-uuid-input" name="tokenUuid" value="${token.uuid}" />
<f:textbox clazz="token-name" name="tokenName" value="${token.name}" />
<j:if test="${token.isLegacy}">
<l:icon class="icon-warning icon-sm" title="${%LegacyToken}"/>
</j:if>
<j:set var="daysOld" value="${token.numDaysCreation}" />
<j:set var="oldClazz" value="age-ok" />
<j:if test="${daysOld > 180}">
<j:set var="oldClazz" value="age-mmmh" />
<j:if test="${daysOld > 360}">
<j:set var="oldClazz" value="age-argh" />
</j:if>
</j:if>
<j:set var="creationDateFormat" value="${%NoLastUse}" />
<j:if test="${token.creationDate != null}">
<i:formatDate var="creationDateFormat" value="${token.creationDate}" type="both" dateStyle="medium" timeStyle="medium" />
</j:if>
<span class="token-creation ${oldClazz}" title="${creationDateFormat}">${%TokenCreation(daysOld)}</span>
<span class="to-right">
<j:choose>
<j:when test="${isStatisticsEnabled}">
<j:set var="useCounter" value="${token.useCounter}" />
<j:choose>
<j:when test="${useCounter > 0}">
<j:set var="lastUseDateFormat" value="${%NoLastUse}" />
<j:if test="${token.lastUseDate != null}">
<i:formatDate var="lastUseDateFormat" value="${token.lastUseDate}" type="both" dateStyle="medium" timeStyle="medium" />
</j:if>
<span class="token-use-counter" title="${lastUseDateFormat}">
${%TokenLastUse(useCounter, token.numDaysUse)}
</span>
</j:when>
<j:otherwise>
<span class="token-use-counter">
<strong title="${%TokenNeverUsed.Title}">
<l:icon class="icon-warning icon-sm"/>
${%TokenNeverUsed}
</strong>
</span>
</j:otherwise>
</j:choose>
</j:when>
<j:otherwise>
<span class="no-statistics" title="${%StatisticsDisabled.Title}">
${%StatisticsDisabled}
</span>
</j:otherwise>
</j:choose>
<a href="#" onclick="return revokeToken(this)" class="token-revoke"
data-message-if-legacy-revoked="${%RevokedToken}"
data-confirm="${%ConfirmRevokeSingle}"
data-target-url="${descriptor.descriptorFullUrl}/revoke">
<l:icon class="icon-text-error icon-sm"/>
</a>
</span>
</div>
</j:when>
<j:otherwise>
<div class="token-list-item token-list-new-item">
<input type="hidden" class="token-uuid-input" name="tokenUuid" value="${token.uuid}" />
<f:textbox name="tokenName" clazz="token-name" placeholder="${%Default name}"/>
<st:nbsp /><!-- without this non-breakable space, double click on the div will put the focus on the input -->
<span class="new-token-value"><!--will be filled by javascript--></span>
<span class="yui-button token-save">
<button type="button" tabindex="0" data-target-url="${descriptor.descriptorFullUrl}/generateNewToken" onclick="saveApiToken(this)">
${%GenerateNewToken}
</button>
</span>
<span class="token-cancel">
<f:repeatableDeleteButton value="${%Cancel}" />
</span>
<a href="#" onclick="return revokeToken(this)" class="token-revoke hidden-button"
data-confirm="${%ConfirmRevokeSingle}"
data-target-url="${descriptor.descriptorFullUrl}/revoke">
<l:icon class="icon-text-error icon-sm"/>
</a>
<div class="error token-error-message"><!-- filled in case of error --></div>
<div class="warning display-after-generation">${%TokenDisplayedOnce}</div>
</div>
</j:otherwise>
</j:choose>
</f:repeatable>
</div>
</f:entry>
<j:if test="${descriptor.mustDisplayLegacyApiToken(it)}">
<f:advanced title="${%Show Legacy API Token}" align="left">
<f:entry title="${%Legacy API Token}" field="apiToken">
<f:readOnlyTextbox id="apiToken" default="${descriptor.noLegacyToken}"/>
</f:entry>
<f:validateButton title="${%Change API Token}" method="changeToken" clazz="ignore-dirty-panel"/>
</f:advanced>
</j:if>
</j:jelly>
TokenDisplayedOnce=Copy this token now, because it cannot be recovered in the future.
AddNewToken=Add new Token
GenerateNewToken=Generate
NoTokenYet=There is no registered token for this user
TokenLastUse=Used <b>{0}</b> time(s), last time was <b>{1}</b> day(s) ago
StatisticsDisabled=No statistics available
StatisticsDisabled.Title=Token usage statistics are currently disabled
TokenNeverUsed=Never used
TokenNeverUsed.Title=We strongly recommend that you revoke tokens which you do not plan to use
ConfirmRevokeSingle=Are you sure you want to revoke this token ? Applications that are using it will be not able to connect anymore.
CurrentTokens=Current token(s)
TokenCreation=Created {0} day(s) ago
RenameToken=Save the new name of the token
LegacyToken=We strongly recommend that you revoke this legacy token and replace it with a newly generated token for increased security.
NoLegacyToken=The user does not have a legacy token
# This file is under the MIT License by authors
Show\ API\ Token=Visualizza token API
API\ Token=Token API
Change\ API\ Token=Modifica token API
# The MIT License
#
# Copyright 2011 Seiji Sogabe
#
# 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.
Show\ API\ Token=API\u30c8\u30fc\u30af\u30f3\u306e\u8868\u793a
API\ Token=API\u30c8\u30fc\u30af\u30f3
Change\ API\ Token=API\u30c8\u30fc\u30af\u30f3\u306e\u5909\u66f4
# This file is under the MIT License by authors
Show\ API\ Token=\u041F\u0440\u0438\u043A\u0430\u0436\u0438 \u0410\u041F\u0418 \u0422\u043E\u043A\u0435\u043D
API\ Token=\u0410\u041F\u0418 \u0422\u043E\u043A\u0435\u043D
Change\ API\ Token=\u0423\u0440\u0435\u0434\u0438 \u0410\u041F\u0418 \u0422\u043E\u043A\u0435\u043D
<div>
This API token can be used for authenticating yourself in the REST API call.
See <a href="https://jenkins.io/redirect/api-token">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>
\ No newline at end of file
<div>
API tokens offer a way to make authenticated CLI or REST API calls.
See <a href="https://jenkins.io/redirect/api-token">our wiki</a> for more details.<br/>
The username associated with each token is your Jenkins username.<br/>
<br/>
Some good practices for keeping your API tokens secure are:
<ul>
<li>Use a different token for each application so that if an application is compromised you can revoke its token individually.</li>
<li>Regenerate the tokens every 6 months (depending on your context). We display an indicator concerning the age of the token.</li>
<li>Protect it like your password, as it allows other people to access Jenkins as you.</li>
</ul>
<div class="warning">
The creation date of legacy tokens which have never been used are reset every time Jenkins is restarted,
which means that the date may be inaccurate.
</div>
</div>
/*
* The MIT License
*
* Copyright (c) 2018, 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.
*/
.token-list{
/* reset to get rid of browser default */
margin: 0;
padding: 0;
max-width: 700px;
border: 1px solid #cccccc;
border-radius: 3px;
}
.token-list .token-list-item {
min-height: inherit;
padding: 8px 10px;
font-size: 13px;
line-height: 26px;
}
.token-list .token-list-item.legacy-token {
padding: 6px 5px 6px 5px;
border: 2px solid #ffe262;
border-left-width: 5px;
border-right-width: 5px;
}
.token-list .token-list-empty-item {
display: block;
}
.token-list .token-list-empty-item.hidden-message {
display: none;
}
.token-list .token-list-item .token-name-input{
font-weight: bold;
}
.token-list .token-list-item .token-creation{
margin-left: 5px;
font-size: 90%;
}
.token-list .token-list-item .token-creation.age-ok{
color: #6d7680;
}
.token-list .token-list-item .token-creation.age-mmmh{
color: #e09307;
}
.token-list .token-list-item .token-creation.age-argh{
color: #de6868;
}
.token-list .token-list-item .token-hide{
display: none;
}
.token-list .token-list-item .to-right{
float: right;
}
.token-list .token-list-item .token-use-counter{
font-size: 90%;
color: #6d7680;
}
.token-list .token-list-item .no-statistics{
font-size: 90%;
color: #6d7680;
}
.token-list .token-list-item .token-revoke{
margin-left: 10px;
}
.token-list .token-list-new-item .token-revoke{
float: right;
line-height: 32px;
}
.token-list .token-list-new-item .token-revoke.hidden-button{
display: none;
}
.token-list .token-list-new-item .token-cancel.hidden-button{
display: none;
}
.token-list .token-list-new-item .token-cancel .yui-button{
vertical-align: baseline;
}
.token-list .token-list-new-item .token-name{
width: 40%;
display: inline-block;
}
.token-list .token-list-new-item .new-token-value{
width: 40%;
display: none;
font-family: monospace;
font-size: 14px;
margin-right: 2px;
}
.token-list .token-list-new-item .new-token-value.visible{
display: inline-block;
}
.token-list .token-list-new-item .display-after-generation{
margin-top: 5px;
display: none;
}
.token-list .token-list-new-item .display-after-generation.visible{
display: block;
}
.token-list .token-list-new-item .token-error-message{
margin-top: 5px;
display: none;
}
.token-list .token-list-new-item .token-error-message.visible{
display: block;
}
.token-list .token-list-new-item .token-save{
vertical-align: baseline;
}
.token-list .repeated-chunk {
border-width: 0;
}
.token-list .repeatable-add{
margin: 6px 6px 3px 6px;
}
\ No newline at end of file
/*
* The MIT License
*
* Copyright (c) 2018, 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.
*/
function revokeToken(anchorRevoke){
var repeatedChunk = anchorRevoke.up('.repeated-chunk');
var tokenList = repeatedChunk.up('.token-list');
var confirmMessage = anchorRevoke.getAttribute('data-confirm');
var targetUrl = anchorRevoke.getAttribute('data-target-url');
var inputUuid = repeatedChunk.querySelector('input.token-uuid-input');
var tokenUuid = inputUuid.value;
if(confirm(confirmMessage)){
new Ajax.Request(targetUrl, {
method: "post",
parameters: {tokenUuid: tokenUuid},
onSuccess: function(rsp,_) {
if(repeatedChunk.querySelectorAll('.legacy-token').length > 0){
// we are revoking the legacy token
var messageIfLegacyRevoked = anchorRevoke.getAttribute('data-message-if-legacy-revoked');
var legacyInput = document.getElementById('apiToken');
legacyInput.value = messageIfLegacyRevoked;
}
repeatedChunk.remove();
adjustTokenEmptyListMessage(tokenList);
}
});
}
return false;
}
function saveApiToken(button){
if(button.hasClassName('request-pending')){
// avoid multiple requests to be sent if user is clicking multiple times
return;
}
button.addClassName('request-pending');
var targetUrl = button.getAttribute('data-target-url');
var repeatedChunk = button.up('.repeated-chunk ');
var tokenList = repeatedChunk.up('.token-list');
var nameInput = repeatedChunk.querySelector('[name="tokenName"]');
var tokenName = nameInput.value;
new Ajax.Request(targetUrl, {
method: "post",
parameters: {"newTokenName": tokenName},
onSuccess: function(rsp,_) {
var json = rsp.responseJSON;
var errorSpan = repeatedChunk.querySelector('.error');
if(json.status === 'error'){
errorSpan.innerHTML = json.message;
errorSpan.addClassName('visible');
button.removeClassName('request-pending');
}else{
errorSpan.removeClassName('visible');
var tokenName = json.data.tokenName;
// in case the name was empty, the application will propose a default one
nameInput.value = tokenName;
var tokenValue = json.data.tokenValue;
var tokenValueSpan = repeatedChunk.querySelector('.new-token-value');
tokenValueSpan.innerText = tokenValue;
tokenValueSpan.addClassName('visible');
var tokenUuid = json.data.tokenUuid;
var uuidInput = repeatedChunk.querySelector('[name="tokenUuid"]');
uuidInput.value = tokenUuid;
var warningMessage = repeatedChunk.querySelector('.display-after-generation');
warningMessage.addClassName('visible');
// we do not want to allow user to create twice a token using same name by mistake
button.remove();
var revokeButton = repeatedChunk.querySelector('.token-revoke');
revokeButton.removeClassName('hidden-button');
var cancelButton = repeatedChunk.querySelector('.token-cancel');
cancelButton.addClassName('hidden-button')
repeatedChunk.addClassName('token-list-fresh-item');
adjustTokenEmptyListMessage(tokenList);
}
}
});
}
function adjustTokenEmptyListMessage(tokenList){
var emptyListMessage = tokenList.querySelector('.token-list-empty-item');
// number of token that are already existing or freshly created
var numOfToken = tokenList.querySelectorAll('.token-list-existing-item, .token-list-fresh-item').length;
if(numOfToken >= 1){
if(!emptyListMessage.hasClassName('hidden-message')){
emptyListMessage.addClassName('hidden-message');
}
}else{
if(emptyListMessage.hasClassName('hidden-message')){
emptyListMessage.removeClassName('hidden-message');
}
}
}
......@@ -24,5 +24,8 @@ ApiTokenProperty.DisplayName=API Token
ApiTokenProperty.ChangeToken.TokenIsHidden=Token is hidden
ApiTokenProperty.ChangeToken.Success=<div>Updated. See the new token in the field above</div>
ApiTokenProperty.ChangeToken.SuccessHidden=<div>Updated. You need to login as the user to see the token</div>
ApiTokenProperty.ChangeToken.CapabilityNotAllowed=<div>Capability to generate a legacy token without an existing one is currently disabled in the security configuration</div>
ApiTokenProperty.LegacyTokenName=Legacy Token
ApiTokenProperty.NoLegacyToken=This user currently does not have a legacy token
RekeySecretAdminMonitor.DisplayName=Re-keying
UpdateSiteWarningsMonitor.DisplayName=Update Site Warnings
<?xml version="1.0" encoding="UTF-8"?>
<!--
The MIT License
Copyright (c) 2018, 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.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:section title="${%API Token}">
<f:entry field="tokenGenerationOnCreationEnabled">
<f:checkbox title="${%tokenGenerationOnCreationEnabled}"/>
</f:entry>
<f:entry field="creationOfLegacyTokenEnabled">
<f:checkbox title="${%creationOfLegacyTokenEnabled}"/>
</f:entry>
<f:entry field="usageStatisticsEnabled">
<f:checkbox title="${%usageStatisticsEnabled}"/>
</f:entry>
</f:section>
</j:jelly>
tokenGenerationOnCreationEnabled=Generate a legacy API token for each newly created user (Not recommended)
creationOfLegacyTokenEnabled=Allow users to manually create a legacy API token (Not recommended)
usageStatisticsEnabled=Enable API Token usage statistics
<div>
This option allows users to generate a legacy API token if they do not already have one.
Because legacy tokens are <strong>deprecated</strong>, we recommend disabling it and having users instead generate
new API tokens from the user configuration page.
</div>
<div>
This option causes a legacy API token to be generated automatically for newly created users.
Because legacy tokens are <strong>deprecated</strong>, we recommend disabling it and having users instead generate
new API tokens from the user configuration page as needed.
</div>
<div>
If this option is enabled, then the date of the most recent use of each API token and the total number of times
it has been used are stored in Jenkins.
This allows users to see if they have unused or outdated API tokens which should be revoked.
<br />
This data is stored in your Jenkins instance and will not be used for any other purpose.
</div>
<!--
The MIT License
Copyright (c) 2018, 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.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<div class="alert alert-warning">
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}">
<f:submit name="yes" value="${%Disable automatic generation of legacy API tokens}"/>
<f:submit name="no" value="${%Dismiss}"/>
</form>
${%warningMessage}
</div>
</j:jelly>
warningMessage=A legacy API token will be generated automatically for newly created users.<br/>\
This behavior is supported for backwards compatibility, but legacy API tokens are deprecated and \
are not recommended for long-term use.<br/>\
Users should instead generate API tokens as needed from the user configuration page.
\ No newline at end of file
<!--
The MIT License
Copyright (c) 2018, 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.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<div class="alert alert-warning">
<form method="post" action="${rootURL}/${it.url}/act" name="${it.id}">
<f:submit name="yes" value="${%Prevent users from manually creating legacy API tokens}"/>
<f:submit name="no" value="${%Dismiss}"/>
</form>
${%warningMessage}
</div>
</j:jelly>
warningMessage=Users without legacy API tokens are able to generate a new legacy token.<br/>\
This behavior is supported for backwards compatibility, but legacy API tokens are deprecated and \
are not recommended for long-term use.<br/>\
Users should instead generate API tokens as needed from the user configuration page.
\ No newline at end of file
<!--
The MIT License
Copyright (c) 2018, 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.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout"
xmlns:t="/lib/hudson" xmlns:f="/lib/form" xmlns:i="jelly:fmt">
<l:layout title="${%title}">
<st:adjunct includes="jenkins.security.apitoken.LegacyApiTokenAdministrativeMonitor.resources"/>
<st:include page="sidepanel.jelly" it="${app}"/>
<l:main-panel>
<div class="legacy-token-usage">
<h1>${%title}</h1>
<p>${%contextMessage}</p>
<p>${%recommendationMessage}</p>
<j:set var="userList" value="${it.impactedUserList}"/>
<table class="pane bigtable align-th-left">
<tr>
<th width="1%"><!-- checkbox --></th>
<th>${%UserId}</th>
<th>${%UserFullName}</th>
<th>${%TokenName}</th>
<th>${%NumDaysSinceCreation}</th>
<th>${%NumOfUse}</th>
<th>${%NumDaysSinceLastUse}</th>
<th title="${%HasFreshToken_tooltip}">
${%HasFreshToken}
<l:icon class="icon-help icon-sm"/>
</th>
<th title="${%HasMoreRecentlyUsedToken_tooltip}">
${%HasMoreRecentlyUsedToken}
<l:icon class="icon-help icon-sm"/>
</th>
</tr>
<j:choose>
<j:when test="${!userList.isEmpty()}">
<j:forEach var="user" items="${userList}">
<j:set var="legacyToken" value="${it.getLegacyTokenOf(user)}"/>
<j:set var="legacyStats" value="${it.getLegacyStatsOf(user, legacyToken)}"/>
<j:set var="creationDateFormat" value="${%NoCreationDate}" />
<j:if test="${legacyToken.creationDate != null}">
<i:formatDate var="creationDateFormat" value="${legacyToken.creationDate}" type="both" dateStyle="medium" timeStyle="medium" />
</j:if>
<j:set var="lastUseDateFormat" value="${%NoLastUse}" />
<j:if test="${legacyStats.lastUseDate != null}">
<i:formatDate var="lastUseDateFormat" value="${legacyStats.lastUseDate}" type="both" dateStyle="medium" timeStyle="medium" />
</j:if>
<j:set var="hasFreshToken"
value="${it.hasFreshToken(user, legacyStats)}"/>
<j:set var="hasMoreRecentlyUsedToken"
value="${it.hasMoreRecentlyUsedToken(user, legacyStats)}"/>
<j:if test="${legacyToken != null}">
<tr>
<td>
<!--future actions-->
<input type="checkbox" value="" name="revoke"
data-user-id="${user.id}"
data-uuid="${legacyToken.uuid}"
class="token-to-revoke ${hasFreshToken ? 'fresh-token' : ''} ${hasMoreRecentlyUsedToken ? 'recent-token' : ''}" />
</td>
<td>
${user.id}
</td>
<td>
${user.fullName}
</td>
<td>
${legacyToken.name}
</td>
<td title="${creationDateFormat}">
${legacyStats.numDaysCreation}
</td>
<td>
${legacyStats.useCounter}
</td>
<td title="${lastUseDateFormat}">
${legacyStats.numDaysUse}
</td>
<td>
<j:choose>
<j:when test="${hasFreshToken}">
<l:icon class="icon-accept icon-sm" alt="${%Fresh token}" tooltip="${%HasFreshToken_ok_tooltip}"/>
</j:when>
<j:otherwise>
<l:icon class="icon-warning icon-sm" alt="${%No fresh token}" tooltip="${%HasFreshToken_warning_tooltip}"/>
</j:otherwise>
</j:choose>
</td>
<td>
<j:choose>
<j:when test="${hasMoreRecentlyUsedToken}">
<l:icon class="icon-accept icon-sm" alt="${%Recently used token}" tooltip="${%HasMoreRecentlyUsedToken_ok_tooltip}"/>
</j:when>
<j:otherwise>
<l:icon class="icon-warning icon-sm" alt="${%No recently used token}" tooltip="${%HasMoreRecentlyUsedToken_warning_tooltip}"/>
</j:otherwise>
</j:choose>
</td>
</tr>
</j:if>
<!-- else: the user revoked its legacy token between the list computation and the display -->
</j:forEach>
</j:when>
<j:otherwise>
<tr class="no-token-line">
<td colspan="9">
<div class="no-token">
${%NoImpactedUser}
</div>
</td>
</tr>
</j:otherwise>
</j:choose>
</table>
<div class="selection-panel">
Select:
<a href="#" class="action" onclick="selectAll(this);return false;">all</a>
<a href="#" class="action" onclick="selectFresh(this);return false;">only fresh</a>
<a href="#" class="action" onclick="selectRecent(this);return false;">only recent</a>
</div>
<div class="action-panel">
<span class="yui-button">
<button class="action-revoke-selected" onclick="confirmAndRevokeAllSelected(this);"
data-url="${rootURL}/${it.url}/revokeAllSelected"
data-confirm-template="${%RevokeAllSelected_confirm}"
data-nothing-selected="${%RevokeAllSelected_nothing}">
${%RevokeAllSelected}
</button>
</span>
</div>
</div>
</l:main-panel>
</l:layout>
</j:jelly>
title=Manage Legacy API Token usage
contextMessage=The following users have a legacy API token. \
Because legacy tokens are stored in a recoverable format, we recommend migrating to the new API token system. \
Additionally, in previous versions of Jenkins, API tokens were automatically created for every new user. \
Often, users did not use the token, resulting in a larger attack surface than necessary. \
Automatic token generation must now be enabled explicitly.
recommendationMessage=For such reasons, we recommend to: \
<ul>\
<li>revoke the tokens that were never used and</li>\
<li>ask users that are currently using their legacy token to generate a token using the new system and revoke their legacy token</li>\
</ul>
UserId=User Id
UserFullName=User full name
TokenName=Token name
NumDaysSinceCreation=Days since creation
NumOfUse=# of use
NumDaysSinceLastUse=Days since last use
HasFreshToken=Fresh token?
HasFreshToken_tooltip=A fresh token is one that was created using the new system \
after the most recent use of the user''s legacy token. \n\
Note: the creation date of legacy tokens which have never been used are reset every time Jenkins is restarted, \
which means that the date may be inaccurate.
HasFreshToken_ok_tooltip=A fresh token exist for that user
HasFreshToken_warning_tooltip=No fresh token exist for that user
HasMoreRecentlyUsedToken=Recent token?
HasMoreRecentlyUsedToken_tooltip=A recently used token is one that was created using the new system \
and used more recently than the user''s legacy token. \n\
Note: the creation date of legacy tokens which have never been used are reset every time Jenkins is restarted, \
which means that the date may be inaccurate.
HasMoreRecentlyUsedToken_ok_tooltip=A recent token exist for that user
HasMoreRecentlyUsedToken_warning_tooltip=No recent token exist for that user
NoImpactedUser=There are no users with a legacy token
NoCreationDate=There is no creation date for that token
NoLastUse=There is no last use date for that token
RevokeAllSelected=Revoke the selected token(s)
RevokeAllSelected_confirm=Are you sure about revoking all %num% selected token(s)
RevokeAllSelected_nothing=No token is selected, please select at least one to revoke
<!--
The MIT License
Copyright (c) 2018, 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.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core">
<div class="alert alert-warning">
${%warningMessage(rootURL, it.url)}
</div>
</j:jelly>
warningMessage=There are users who are still using a legacy API token. \
That system is not as secure as the new one because it stores the token in a recoverable manner on the disk. <br />\
See <a href="{0}/{1}/manage">list of impacted users</a>.
/*
* The MIT License
*
* Copyright (c) 2018, 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.
*/
.legacy-token-usage table.align-th-left th {
text-align: left;
}
.legacy-token-usage table .no-token {
padding: 8px 12px;
font-style: italic;
}
.legacy-token-usage table tr.selected {
background-color: #f9f8de;
}
.legacy-token-usage .selection-panel{
margin-top: 8px;
}
.legacy-token-usage .selection-panel .action{
margin-left: 4px;
margin-right: 4px;
text-decoration: none;
color: #204A87;
}
.legacy-token-usage .selection-panel .action:hover{
text-decoration: underline;
}
.legacy-token-usage .selection-panel .action:visited{
/* avoid visited behavior */
color: #204A87;
}
.legacy-token-usage .action-panel{
margin-top: 8px;
}
\ No newline at end of file
/*
* The MIT License
*
* Copyright (c) 2018, 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.
*/
function selectAll(anchor){
var parent = anchor.up('.legacy-token-usage');
var allCheckBoxes = parent.querySelectorAll('.token-to-revoke');
var concernedCheckBoxes = allCheckBoxes;
checkTheDesiredOne(allCheckBoxes, concernedCheckBoxes);
}
function selectFresh(anchor){
var parent = anchor.up('.legacy-token-usage');
var allCheckBoxes = parent.querySelectorAll('.token-to-revoke');
var concernedCheckBoxes = parent.querySelectorAll('.token-to-revoke.fresh-token');
checkTheDesiredOne(allCheckBoxes, concernedCheckBoxes);
}
function selectRecent(anchor){
var parent = anchor.up('.legacy-token-usage');
var allCheckBoxes = parent.querySelectorAll('.token-to-revoke');
var concernedCheckBoxes = parent.querySelectorAll('.token-to-revoke.recent-token');
checkTheDesiredOne(allCheckBoxes, concernedCheckBoxes);
}
function checkTheDesiredOne(allCheckBoxes, concernedCheckBoxes){
var mustCheck = false;
for(var i = 0; i < concernedCheckBoxes.length && !mustCheck ; i++){
var checkBox = concernedCheckBoxes[i];
if(!checkBox.checked){
mustCheck = true;
}
}
for(var i = 0; i < allCheckBoxes.length ; i++){
var checkBox = allCheckBoxes[i];
checkBox.checked = false;
}
for(var i = 0; i < concernedCheckBoxes.length ; i++){
var checkBox = concernedCheckBoxes[i];
checkBox.checked = mustCheck;
}
for(var i = 0; i < allCheckBoxes.length ; i++){
var checkBox = allCheckBoxes[i];
onCheckChanged(checkBox);
}
}
function confirmAndRevokeAllSelected(button){
var parent = button.up('.legacy-token-usage');
var allCheckBoxes = parent.querySelectorAll('.token-to-revoke');
var allCheckedCheckBoxes = [];
for(var i = 0; i < allCheckBoxes.length ; i++){
var checkBox = allCheckBoxes[i];
if(checkBox.checked){
allCheckedCheckBoxes.push(checkBox);
}
}
if(allCheckedCheckBoxes.length == 0){
var nothingSelected = button.getAttribute('data-nothing-selected');
alert(nothingSelected);
}else{
var confirmMessageTemplate = button.getAttribute('data-confirm-template');
var confirmMessage = confirmMessageTemplate.replace('%num%', allCheckedCheckBoxes.length);
if(confirm(confirmMessage)){
var url = button.getAttribute('data-url');
var selectedValues = [];
for(var i = 0; i < allCheckedCheckBoxes.length ; i++){
var checkBox = allCheckedCheckBoxes[i];
var userId = checkBox.getAttribute('data-user-id');
var uuid = checkBox.getAttribute('data-uuid');
selectedValues.push({userId: userId, uuid: uuid});
}
var params = {values: selectedValues}
new Ajax.Request(url, {
postBody: Object.toJSON(params),
contentType:"application/json",
encoding:"UTF-8",
onComplete: function(rsp) {
window.location.reload();
}
});
}
}
}
function onLineClicked(event){
var line = this;
var checkBox = line.querySelector('.token-to-revoke');
// to allow click on checkbox to act normally
if(event.target === checkBox){
return;
}
checkBox.checked = !checkBox.checked;
onCheckChanged(checkBox);
}
function onCheckChanged(checkBox){
var line = checkBox.up('tr');
if(checkBox.checked){
line.addClassName('selected');
}else{
line.removeClassName('selected');
}
}
(function(){
document.addEventListener("DOMContentLoaded", function() {
var allLines = document.querySelectorAll('.legacy-token-usage table tr');
for(var i = 0; i < allLines.length; i++){
var line = allLines[i];
if(!line.hasClassName('no-token-line')){
line.onclick = onLineClicked;
}
}
var allCheckBoxes = document.querySelectorAll('.token-to-revoke');
for(var i = 0; i < allCheckBoxes.length; i++){
var checkBox = allCheckBoxes[i];
checkBox.onchange = function(){ onCheckChanged(checkBox); };
}
});
})()
\ No newline at end of file
# The MIT License
#
# Copyright (c) 2013, Chunghwa Telecom Co., Ltd., Pei-Tang Huang
# Copyright (c) 2018, 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
......@@ -19,7 +19,6 @@
# 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.
Show\ API\ Token=\u986f\u793a API Token
API\ Token=API Token
Change\ API\ Token=\u8b8a\u66f4 API Token
ApiTokenPropertyDisabledDefaultAdministrativeMonitor.displayName=Legacy API Token not generated by default
ApiTokenPropertyEnabledNewLegacyAdministrativeMonitor.displayName=Legacy API Token can be created even without existing
LegacyApiTokenAdministrativeMonitor.displayName=Legacy API Token usage
......@@ -15,6 +15,31 @@
return errorMessage;
}
}
function isIgnoringConfirm(element){
if(element.hasClassName('force-dirty')){
return false;
}
if(element.hasClassName('ignore-dirty')){
return true;
}
// to allow sub-section of the form to ignore confirm
// especially useful for "pure" JavaScript area
// we try to gather the first parent with a marker,
var dirtyPanel = element.up('.ignore-dirty-panel,.force-dirty-panel');
if(!dirtyPanel){
return false;
}
if(dirtyPanel.hasClassName('force-dirty-panel')){
return false;
}
if(dirtyPanel.hasClassName('ignore-dirty-panel')){
return true;
}
return false;
}
function isModifyingButton(btn) {
// TODO don't consider hetero list 'add' buttons
......@@ -27,6 +52,10 @@
// don't consider 'advanced' buttons
return false;
}
if(isIgnoringConfirm(btn)){
return false;
}
// default to true
return true;
......@@ -45,35 +74,45 @@
var buttons = configForm.getElementsByTagName("button");
var name;
for ( var i = 0; i < buttons.length; i++) {
name = buttons[i].parentNode.parentNode.getAttribute('name');
var button = buttons[i];
name = button.parentNode.parentNode.getAttribute('name');
if (name == "Submit" || name == "Apply" || name == "OK") {
$(buttons[i]).on('click', function() {
$(button).on('click', function() {
needToConfirm = false;
});
} else {
if (isModifyingButton(buttons[i])) {
$(buttons[i]).on('click', confirm);
if (isModifyingButton(button)) {
$(button).on('click', confirm);
}
}
}
var inputs = configForm.getElementsByTagName("input");
for ( var i = 0; i < inputs.length; i++) {
if (inputs[i].type == 'checkbox' || inputs[i].type == 'radio') {
$(inputs[i]).on('click', confirm);
} else {
$(inputs[i]).on('input', confirm);
var input = inputs[i];
if(!isIgnoringConfirm(input)){
if (input.type == 'checkbox' || input.type == 'radio') {
$(input).on('click', confirm);
} else {
$(input).on('input', confirm);
}
}
}
inputs = configForm.getElementsByTagName("select");
for ( var i = 0; i < inputs.length; i++) {
$(inputs[i]).on('change', confirm);
var input = inputs[i];
if(!isIgnoringConfirm(input)){
$(input).on('change', confirm);
}
}
inputs = configForm.getElementsByTagName("textarea");
for ( var i = 0; i < inputs.length; i++) {
$(inputs[i]).on('input', confirm);
var input = inputs[i];
if(!isIgnoringConfirm(input)){
$(input).on('input', confirm);
}
}
}
......
......@@ -45,10 +45,14 @@ THE SOFTWARE.
<st:attribute name="with">
','-separated list of fields that are sent to the server.
</st:attribute>
<st:attribute name="clazz">
Additional CSS class(es) to add (such as client-side validation clazz="required",
"number" or "positive-number"; these may be combined, as clazz="required number").
</st:attribute>
</st:documentation>
<f:entry>
<div style="float:right">
<input type="button" value="${title}" class="yui-button validate-button" onclick="validateButton('${descriptor.descriptorFullUrl}/${h.jsStringEscape(method)}','${h.jsStringEscape(with)}',this)" />
<input type="button" value="${title}" class="yui-button validate-button ${attrs.clazz}" onclick="validateButton('${descriptor.descriptorFullUrl}/${h.jsStringEscape(method)}','${h.jsStringEscape(with)}',this)" />
</div>
<div style="display:none;">
<img src="${imagesURL}/spinner.gif" /> ${attrs.progress}
......
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import hudson.XmlFile;
import org.apache.commons.io.FileUtils;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.mockito.internal.matchers.LessOrEqual;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAccessor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Random;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
@RunWith(PowerMockRunner.class)
@PrepareForTest(ApiTokenPropertyConfiguration.class)
public class ApiTokenStatsTest {
@Rule
public TemporaryFolder tmp = new TemporaryFolder();
@Before
public void prepareConfig() throws Exception {
// to separate completely the class under test from its environment
ApiTokenPropertyConfiguration mockConfig = Mockito.mock(ApiTokenPropertyConfiguration.class);
Mockito.when(mockConfig.isUsageStatisticsEnabled()).thenReturn(true);
PowerMockito.mockStatic(ApiTokenPropertyConfiguration.class);
PowerMockito.when(ApiTokenPropertyConfiguration.class, "get").thenReturn(mockConfig);
}
@Test
public void regularUsage() throws Exception {
final String ID_1 = UUID.randomUUID().toString();
final String ID_2 = "other-uuid";
{ // empty stats can be saved
ApiTokenStats tokenStats = new ApiTokenStats();
tokenStats.setParent(tmp.getRoot());
// can remove an id that does not exist
tokenStats.removeId(ID_1);
tokenStats.save();
}
{ // and then loaded, empty stats is empty
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
assertNotNull(tokenStats);
ApiTokenStats.SingleTokenStats stats = tokenStats.findTokenStatsById(ID_1);
assertEquals(0, stats.getUseCounter());
assertNull(stats.getLastUseDate());
assertEquals(0L, stats.getNumDaysUse());
}
Date lastUsage;
{ // then re-notify the same token
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
ApiTokenStats.SingleTokenStats stats = tokenStats.updateUsageForId(ID_1);
assertEquals(1, stats.getUseCounter());
lastUsage = stats.getLastUseDate();
assertNotNull(lastUsage);
// to avoid flaky test in case the test is run at midnight, normally it's 0
assertThat(stats.getNumDaysUse(), lessThanOrEqualTo(1L));
}
// to enforce a difference in the lastUseDate
Thread.sleep(10);
{ // then re-notify the same token
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
ApiTokenStats.SingleTokenStats stats = tokenStats.updateUsageForId(ID_1);
assertEquals(2, stats.getUseCounter());
assertThat(lastUsage, lessThan(stats.getLastUseDate()));
// to avoid flaky test in case the test is run at midnight, normally it's 0
assertThat(stats.getNumDaysUse(), lessThanOrEqualTo(1L));
}
{ // check all tokens have separate stats, try with another ID
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
{
ApiTokenStats.SingleTokenStats stats = tokenStats.findTokenStatsById(ID_2);
assertEquals(0, stats.getUseCounter());
assertNull(stats.getLastUseDate());
assertEquals(0L, stats.getNumDaysUse());
}
{
ApiTokenStats.SingleTokenStats stats = tokenStats.updateUsageForId(ID_2);
assertEquals(1, stats.getUseCounter());
assertNotNull(lastUsage);
assertThat(stats.getNumDaysUse(), lessThanOrEqualTo(1L));
}
}
{ // reload the stats, check the counter are correct
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
ApiTokenStats.SingleTokenStats stats_1 = tokenStats.findTokenStatsById(ID_1);
assertEquals(2, stats_1.getUseCounter());
ApiTokenStats.SingleTokenStats stats_2 = tokenStats.findTokenStatsById(ID_2);
assertEquals(1, stats_2.getUseCounter());
tokenStats.removeId(ID_1);
}
{ // after a removal, the existing must keep its value
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
ApiTokenStats.SingleTokenStats stats_1 = tokenStats.findTokenStatsById(ID_1);
assertEquals(0, stats_1.getUseCounter());
ApiTokenStats.SingleTokenStats stats_2 = tokenStats.findTokenStatsById(ID_2);
assertEquals(1, stats_2.getUseCounter());
}
}
@Test
public void testResilientIfFileDoesNotExist() throws Exception {
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
assertNotNull(tokenStats);
}
@Test
public void resistantToDuplicatedUuid() throws Exception {
final String ID_1 = UUID.randomUUID().toString();
final String ID_2 = UUID.randomUUID().toString();
final String ID_3 = UUID.randomUUID().toString();
{ // put counter to 4 for ID_1 and to 2 for ID_2 and 1 for ID_3
ApiTokenStats tokenStats = new ApiTokenStats();
tokenStats.setParent(tmp.getRoot());
tokenStats.updateUsageForId(ID_1);
tokenStats.updateUsageForId(ID_1);
tokenStats.updateUsageForId(ID_1);
tokenStats.updateUsageForId(ID_1);
tokenStats.updateUsageForId(ID_2);
tokenStats.updateUsageForId(ID_3);
// only the most recent information is kept
tokenStats.updateUsageForId(ID_2);
}
{ // replace the ID_1 with ID_2 in the file
XmlFile statsFile = ApiTokenStats.getConfigFile(tmp.getRoot());
String content = FileUtils.readFileToString(statsFile.getFile());
// now there are multiple times the same id in the file with different stats
String newContentWithDuplicatedId = content.replace(ID_1, ID_2).replace(ID_3, ID_2);
FileUtils.write(statsFile.getFile(), newContentWithDuplicatedId);
}
{
ApiTokenStats tokenStats = ApiTokenStats.load(tmp.getRoot());
assertNotNull(tokenStats);
ApiTokenStats.SingleTokenStats stats_1 = tokenStats.findTokenStatsById(ID_1);
assertEquals(0, stats_1.getUseCounter());
// the most recent information is kept
ApiTokenStats.SingleTokenStats stats_2 = tokenStats.findTokenStatsById(ID_2);
assertEquals(2, stats_2.getUseCounter());
ApiTokenStats.SingleTokenStats stats_3 = tokenStats.findTokenStatsById(ID_3);
assertEquals(0, stats_3.getUseCounter());
}
}
@Test
public void resistantToDuplicatedUuid_withNull() throws Exception {
final String ID = "ID";
{ // prepare
List<ApiTokenStats.SingleTokenStats> tokenStatsList = Arrays.asList(
/* A */ createSingleTokenStatsByReflection(ID, null, 0),
/* B */ createSingleTokenStatsByReflection(ID, "2018-05-01 09:10:59.234", 2),
/* C */ createSingleTokenStatsByReflection(ID, "2018-05-01 09:10:59.234", 3),
/* D */ createSingleTokenStatsByReflection(ID, "2018-05-01 09:10:59.235", 1)
);
ApiTokenStats stats = new ApiTokenStats();
Field field = ApiTokenStats.class.getDeclaredField("tokenStats");
field.setAccessible(true);
field.set(stats, tokenStatsList);
stats.setParent(tmp.getRoot());
stats.save();
}
{ // reload to see the effect
ApiTokenStats stats = ApiTokenStats.load(tmp.getRoot());
ApiTokenStats.SingleTokenStats tokenStats = stats.findTokenStatsById(ID);
// must be D (as it was the last updated one)
assertThat(tokenStats.getUseCounter(), equalTo(1));
}
}
@Test
@SuppressWarnings("unchecked")
public void testInternalComparator() throws Exception {
List<ApiTokenStats.SingleTokenStats> tokenStatsList = Arrays.asList(
createSingleTokenStatsByReflection("A", null, 0),
createSingleTokenStatsByReflection("B", "2018-05-01 09:10:59.234", 2),
createSingleTokenStatsByReflection("C", "2018-05-01 09:10:59.234", 3),
createSingleTokenStatsByReflection("D", "2018-05-01 09:10:59.235", 1)
);
Field field = ApiTokenStats.SingleTokenStats.class.getDeclaredField("COMP_BY_LAST_USE_THEN_COUNTER");
field.setAccessible(true);
Comparator<ApiTokenStats.SingleTokenStats> comparator = (Comparator<ApiTokenStats.SingleTokenStats>) field.get(null);
// to be not impacted by the declaration order
Collections.shuffle(tokenStatsList, new Random(42));
tokenStatsList.sort(comparator);
List<String> idList = tokenStatsList.stream()
.map(ApiTokenStats.SingleTokenStats::getTokenUuid)
.collect(Collectors.toList());
assertThat(idList, contains("A", "B", "C", "D"));
}
private ApiTokenStats.SingleTokenStats createSingleTokenStatsByReflection(String uuid, String dateString, Integer counter) throws Exception {
Class<ApiTokenStats.SingleTokenStats> clazz = ApiTokenStats.SingleTokenStats.class;
Constructor<ApiTokenStats.SingleTokenStats> constructor = clazz.getDeclaredConstructor(String.class);
constructor.setAccessible(true);
ApiTokenStats.SingleTokenStats result = constructor.newInstance(uuid);
{
Field field = clazz.getDeclaredField("useCounter");
field.setAccessible(true);
field.set(result, counter);
}
if(dateString != null){
Field field = clazz.getDeclaredField("lastUseDate");
field.setAccessible(true);
field.set(result, new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").parse(dateString));
}
return result;
}
@Test
public void testDayDifference() throws Exception {
final String ID = UUID.randomUUID().toString();
ApiTokenStats tokenStats = new ApiTokenStats();
tokenStats.setParent(tmp.getRoot());
ApiTokenStats.SingleTokenStats stats = tokenStats.updateUsageForId(ID);
assertThat(stats.getNumDaysUse(), lessThan(1L));
Field field = ApiTokenStats.SingleTokenStats.class.getDeclaredField("lastUseDate");
field.setAccessible(true);
field.set(stats, new Date(
new Date().toInstant()
.minus(2, ChronoUnit.DAYS)
// to ensure we have more than 2 days
.minus(5, ChronoUnit.MINUTES)
.toEpochMilli()
)
);
assertThat(stats.getNumDaysUse(), greaterThanOrEqualTo(2L));
}
}
......@@ -37,7 +37,9 @@ import hudson.slaves.JNLPLauncher;
import hudson.slaves.RetentionStrategy;
import hudson.slaves.DumbSlave;
import hudson.util.StreamTaskListener;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.MasterToSlaveCallable;
import jenkins.security.apitoken.ApiTokenTestHelper;
import jenkins.security.s2m.AdminWhitelistRule;
import org.dom4j.Document;
import org.dom4j.Element;
......@@ -85,6 +87,8 @@ public class JnlpAccessWithSecuredHudsonTest {
@Email("http://markmail.org/message/on4wkjdaldwi2atx")
@Test
public void anonymousCanAlwaysLoadJARs() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
r.jenkins.setNodes(Collections.singletonList(createNewJnlpSlave("test")));
JenkinsRule.WebClient wc = r.createWebClient();
HtmlPage p = wc.withBasicApiToken(User.getById("alice", true)).goTo("computer/test/");
......
......@@ -33,6 +33,8 @@ import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import jenkins.model.Jenkins;
import jenkins.security.ApiTokenProperty;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import jenkins.util.FullDuplexHttpService;
import jenkins.util.Timer;
import org.apache.commons.io.FileUtils;
......@@ -141,6 +143,8 @@ public class CLIActionTest {
@Issue({"JENKINS-12543", "JENKINS-41745"})
@Test
public void authentication() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
logging.record(PlainCLIProtocol.class, Level.FINE);
File jar = tmp.newFile("jenkins-cli.jar");
FileUtils.copyURLToFile(j.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar);
......
......@@ -12,6 +12,8 @@ import com.gargoylesoftware.htmlunit.util.NameValuePair;
import hudson.model.User;
import hudson.security.GlobalMatrixAuthorizationStrategy;
import jenkins.model.Jenkins;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.Issue;
......@@ -59,6 +61,8 @@ public class HudsonHomeDiskUsageMonitorTest {
@Issue("SECURITY-371")
@Test
public void noAccessForNonAdmin() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
JenkinsRule.WebClient wc = j.createWebClient();
// TODO: Use MockAuthorizationStrategy in later versions
......
......@@ -34,13 +34,14 @@ import hudson.cli.CLICommandInvoker;
import hudson.cli.CopyJobCommand;
import hudson.cli.CreateJobCommand;
import hudson.security.ACL;
import hudson.security.csrf.CrumbIssuer;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Arrays;
import jenkins.model.Jenkins;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.acegisecurity.context.SecurityContext;
import org.acegisecurity.context.SecurityContextHolder;
import org.apache.commons.httpclient.HttpStatus;
......@@ -62,6 +63,11 @@ public class ItemsTest {
@Rule public JenkinsRule r = new JenkinsRule();
@Rule public TemporaryFolder tmpRule = new TemporaryFolder();
@Before
public void setupLegacyBehavior(){
ApiTokenTestHelper.enableLegacyBehavior();
}
@Test public void getAllItems() throws Exception {
MockFolder d = r.createFolder("d");
MockFolder sub2 = d.createProject(MockFolder.class, "sub2");
......
......@@ -52,6 +52,8 @@ import java.util.concurrent.CountDownLatch;
import jenkins.model.ProjectNamingStrategy;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.hamcrest.Matchers;
import org.junit.Rule;
import org.junit.Test;
......@@ -210,6 +212,8 @@ public class JobTest {
@LocalData
@Test public void configDotXmlPermission() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
j.jenkins.setCrumbIssuer(null);
JenkinsRule.WebClient wc = j.createWebClient();
boolean saveEnabled = Item.EXTENDED_READ.getEnabled();
......
......@@ -30,6 +30,9 @@ import hudson.Launcher;
import java.io.IOException;
import jenkins.model.Jenkins;
import static org.junit.Assert.assertEquals;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.Issue;
......@@ -50,6 +53,8 @@ public class PasswordParameterDefinitionTest {
@Issue("JENKINS-36476")
@Test public void defaultValueAlwaysAvailable() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().
grant(Jenkins.ADMINISTER).everywhere().to("admin").
......
......@@ -72,7 +72,9 @@ import hudson.util.OneShotEvent;
import hudson.util.XStream2;
import jenkins.model.BlockedBecauseOfBuildInProgress;
import jenkins.model.Jenkins;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.QueueItemAuthenticatorConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import jenkins.triggers.ReverseBuildTrigger;
import org.acegisecurity.Authentication;
import org.acegisecurity.GrantedAuthority;
......@@ -926,6 +928,7 @@ public class QueueTest {
@Issue({"SECURITY-186", "SECURITY-618"})
@Test
public void queueApiOutputShouldBeFilteredByUserPermission() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
r.jenkins.setSecurityRealm(r.createDummySecurityRealm());
ProjectMatrixAuthorizationStrategy str = new ProjectMatrixAuthorizationStrategy();
......
......@@ -56,6 +56,8 @@ import jenkins.model.IdStrategy;
import jenkins.model.Jenkins;
import jenkins.security.ApiTokenProperty;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.acegisecurity.AccessDeniedException;
import org.acegisecurity.Authentication;
import org.acegisecurity.AuthenticationException;
......@@ -531,6 +533,8 @@ public class UserTest {
@Test
// @Issue("SECURITY-180")
public void security180() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
final GlobalMatrixAuthorizationStrategy auth = new GlobalMatrixAuthorizationStrategy();
j.jenkins.setAuthorizationStrategy(auth);
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
......
......@@ -51,7 +51,9 @@ import static org.hamcrest.Matchers.not;
import static org.hamcrest.xml.HasXPath.hasXPath;
import static org.junit.Assert.*;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.SecurityListener;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.apache.commons.lang.StringUtils;
import java.io.UnsupportedEncodingException;
......@@ -180,6 +182,8 @@ public class HudsonPrivateSecurityRealmTest {
@Issue("SECURITY-243")
@Test
public void fullNameCollisionToken() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null);
j.jenkins.setSecurityRealm(securityRealm);
......
......@@ -12,6 +12,8 @@ import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlCheckBoxInput;
import hudson.model.User;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
......@@ -53,6 +55,8 @@ public class LoginTest {
@Test
@PresetData(DataSet.ANONYMOUS_READONLY)
public void loginErrorRedirect2() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
// in a secured Hudson, the error page should render.
WebClient wc = j.createWebClient();
wc.assertFails("loginError", SC_UNAUTHORIZED);
......
......@@ -44,6 +44,9 @@ import java.util.Map;
import jenkins.model.Jenkins;
import static org.junit.Assert.*;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import net.sf.json.JSONObject;
import org.junit.Rule;
......@@ -186,6 +189,8 @@ public class RobustReflectionConverterTest {
@Test
public void testRestInterfaceFailure() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
Items.XSTREAM2.addCriticalField(KeywordProperty.class, "criticalField");
User test = User.getById("test", true);
......
......@@ -23,10 +23,8 @@
*/
package jenkins.model;
import static hudson.init.InitMilestone.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
......@@ -45,7 +43,6 @@ import com.gargoylesoftware.htmlunit.WebResponse;
import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import hudson.init.Initializer;
import hudson.maven.MavenModuleSet;
import hudson.maven.MavenModuleSetBuild;
import hudson.model.Computer;
......@@ -67,6 +64,8 @@ import hudson.util.FormValidation;
import hudson.util.VersionNumber;
import jenkins.AgentProtocol;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.Issue;
......@@ -86,7 +85,7 @@ import java.net.URL;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;
import org.jvnet.hudson.test.MockAuthorizationStrategy;
import javax.annotation.CheckForNull;
......@@ -296,6 +295,8 @@ public class JenkinsTest {
@Test
public void testDoScript() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().
grant(Jenkins.ADMINISTER).everywhere().to("alice").
......@@ -324,6 +325,8 @@ public class JenkinsTest {
@Test
public void testDoEval() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().
grant(Jenkins.ADMINISTER).everywhere().to("alice").
......
......@@ -34,7 +34,8 @@ import hudson.model.UnprotectedRootAction;
import hudson.model.User;
import hudson.security.csrf.DefaultCrumbIssuer;
import hudson.util.HttpResponses;
import hudson.util.Scrambler;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.Issue;
......@@ -59,6 +60,8 @@ public class ApiCrumbExclusionTest {
@Test
@Issue("JENKINS-22474")
public void callUsingApiTokenDoesNotRequireCSRFToken() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
j.jenkins.setCrumbIssuer(null);
User foo = User.get("foo");
......
package jenkins.security;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.xml.HasXPath.hasXPath;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;
import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
import com.gargoylesoftware.htmlunit.HttpMethod;
import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.WebRequest;
import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.gargoylesoftware.htmlunit.util.NameValuePair;
import com.gargoylesoftware.htmlunit.xml.XmlPage;
import hudson.Util;
import hudson.model.Cause;
import hudson.model.FreeStyleProject;
import hudson.model.User;
import hudson.security.ACL;
import hudson.security.ACLContext;
import hudson.util.Scrambler;
import java.net.URL;
import jenkins.model.Jenkins;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenStore;
import jenkins.security.apitoken.ApiTokenTestHelper;
import net.sf.json.JSONObject;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.JenkinsRule.WebClient;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.recipes.LocalData;
/**
* @author Kohsuke Kawaguchi
......@@ -38,13 +53,18 @@ public class ApiTokenPropertyTest {
@Rule
public JenkinsRule j = new JenkinsRule();
@Before
public void setupLegacyConfig(){
ApiTokenTestHelper.enableLegacyBehavior();
}
/**
* Tests the UI interaction and authentication.
*/
@Test
public void basics() throws Exception {
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
User u = User.get("foo");
User u = User.getById("foo", true);
final ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
final String token = t.getApiToken();
......@@ -81,7 +101,7 @@ public class ApiTokenPropertyTest {
ApiTokenProperty t = new ApiTokenProperty(historicalInitialValue);
u.addProperty(t);
String apiToken1 = t.getApiToken();
assertFalse(apiToken1.equals(Util.getDigestOf(historicalInitialValue)));
assertNotEquals(apiToken1, Util.getDigestOf(historicalInitialValue));
// the replacement for the compromised value must be consistent and cannot be random
ApiTokenProperty t2 = new ApiTokenProperty(historicalInitialValue);
......@@ -91,7 +111,7 @@ public class ApiTokenPropertyTest {
// any other value is OK. those are changed values
t = new ApiTokenProperty(historicalInitialValue+"somethingElse");
u.addProperty(t);
assertTrue(t.getApiToken().equals(Util.getDigestOf(historicalInitialValue+"somethingElse")));
assertEquals(t.getApiToken(), Util.getDigestOf(historicalInitialValue+"somethingElse"));
}
@Issue("SECURITY-200")
......@@ -138,7 +158,7 @@ public class ApiTokenPropertyTest {
public void postWithUsernameAndTokenInBasicAuthHeader() throws Exception {
FreeStyleProject p = j.createFreeStyleProject("bar");
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
User.get("foo");
User.getById("foo", true);
WebClient wc = createClientForUser("foo");
WebRequest wr = new WebRequest(new URL(j.getURL(), "job/bar/build"), HttpMethod.POST);
......@@ -152,18 +172,291 @@ public class ApiTokenPropertyTest {
}
@Nonnull
private WebClient createClientForUser(final String username) throws Exception {
final String token = getUserToken(username);
private WebClient createClientForUser(final String id) throws Exception {
User u = User.getById(id, true);
WebClient wc = j.createWebClient();
wc.addRequestHeader("Authorization", "Basic " + Scrambler.scramble(username + ":" + token));
wc.withBasicApiToken(u);
return wc;
}
private String getUserToken(String username) {
User u = User.get(username);
final ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
// Yes, we use the insecure call in the test stuff
return t.getApiTokenInsecure();
@Test
@Issue("JENKINS-32776")
public void generateNewTokenWithoutName() throws Exception {
j.jenkins.setCrumbIssuer(null);
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
// user is still able to connect with legacy token
User admin = User.getById("admin", true);
WebClient wc = j.createWebClient();
wc.withBasicCredentials("admin", "admin");
GenerateNewTokenResponse token1 = generateNewToken(wc, "admin", "");
assertNotEquals("", token1.tokenName.trim());
GenerateNewTokenResponse token2 = generateNewToken(wc, "admin", "New Token");
assertEquals("New Token", token2.tokenName);
}
@Test
@LocalData
@Issue("JENKINS-32776")
public void migrationFromLegacyToken() throws Exception {
j.jenkins.setCrumbIssuer(null);
// user is still able to connect with legacy token
User admin = User.getById("admin", false);
assertNotNull("Admin user not configured correctly in local data", admin);
ApiTokenProperty apiTokenProperty = admin.getProperty(ApiTokenProperty.class);
WebClient wc = j.createWebClient();
wc.withBasicCredentials("admin", "admin");
checkUserIsConnected(wc);
// 7be8e81ad5a350fa3f3e2acfae4adb14
String localLegacyToken = apiTokenProperty.getApiTokenInsecure();
wc = j.createWebClient();
wc.withBasicCredentials("admin", localLegacyToken);
checkUserIsConnected(wc);
// can still renew it after (using API)
assertEquals(1, apiTokenProperty.getTokenList().size());
apiTokenProperty.changeApiToken();
assertEquals(1, apiTokenProperty.getTokenList().size());
String newLegacyToken = apiTokenProperty.getApiTokenInsecure();
// use the new legacy api token
wc = j.createWebClient();
wc.withBasicCredentials("admin", newLegacyToken);
checkUserIsConnected(wc);
// but previous one is not more usable
wc = j.createWebClient();
wc.withBasicCredentials("admin", localLegacyToken);
checkUserIsNotConnected(wc);
// ===== new system =====
// revoke the legacy
ApiTokenStore.HashedToken legacyToken = apiTokenProperty.getTokenStore().getLegacyToken();
assertNotNull(legacyToken);
String legacyUuid = legacyToken.getUuid();
wc = j.createWebClient();
wc.withBasicCredentials("admin", newLegacyToken);
revokeToken(wc, "admin", legacyUuid);
assertEquals(0, apiTokenProperty.getTokenList().size());
// check it does not work any more
wc = j.createWebClient();
wc.withBasicCredentials("admin", newLegacyToken);
checkUserIsNotConnected(wc);
wc = j.createWebClient();
wc.withBasicCredentials("admin", localLegacyToken);
checkUserIsNotConnected(wc);
// ensure the user can still connect using its username / password
wc = j.createWebClient();
wc.withBasicCredentials("admin", "admin");
checkUserIsConnected(wc);
// generate new token with the new system
wc = j.createWebClient();
wc.login("admin", "admin");
GenerateNewTokenResponse newToken = generateNewToken(wc, "admin", "New Token");
// use the new one
wc = j.createWebClient();
wc.withBasicCredentials("admin", newToken.tokenValue);
checkUserIsConnected(wc);
}
private void checkUserIsConnected(WebClient wc) throws Exception {
XmlPage xmlPage = wc.goToXml("whoAmI/api/xml");
assertThat(xmlPage, hasXPath("//name", is("admin")));
assertThat(xmlPage, hasXPath("//anonymous", is("false")));
assertThat(xmlPage, hasXPath("//authenticated", is("true")));
assertThat(xmlPage, hasXPath("//authority", is("authenticated")));
}
private void checkUserIsNotConnected(WebClient wc) throws Exception {
try{
wc.goToXml("whoAmI/api/xml");
fail();
}
catch(FailingHttpStatusCodeException e){
assertEquals(401, e.getStatusCode());
}
}
@Test
@Issue("JENKINS-32776")
public void legacyTokenChange() throws Exception {
j.jenkins.setCrumbIssuer(null);
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
ApiTokenPropertyConfiguration config = ApiTokenPropertyConfiguration.get();
config.setTokenGenerationOnCreationEnabled(true);
User user = User.getById("user", true);
WebClient wc = j.createWebClient();
wc.withBasicCredentials("user", "user");
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
{ // with one legacy token, we can change it using web UI or direct internal call
String currentLegacyToken = apiTokenProperty.getApiToken();
assertEquals(1, apiTokenProperty.getTokenList().size());
config.setCreationOfLegacyTokenEnabled(true);
{
// change using web UI
changeLegacyToken(wc, "user", true);
String newLegacyToken = apiTokenProperty.getApiToken();
assertNotEquals(newLegacyToken, currentLegacyToken);
// change using internal call
apiTokenProperty.changeApiToken();
String newLegacyToken2 = apiTokenProperty.getApiToken();
assertNotEquals(newLegacyToken2, newLegacyToken);
assertNotEquals(newLegacyToken2, currentLegacyToken);
currentLegacyToken = newLegacyToken2;
}
config.setCreationOfLegacyTokenEnabled(false);
{
// change using web UI
changeLegacyToken(wc, "user", true);
String newLegacyToken = apiTokenProperty.getApiToken();
assertNotEquals(newLegacyToken, currentLegacyToken);
// change using internal call
apiTokenProperty.changeApiToken();
String newLegacyToken2 = apiTokenProperty.getApiToken();
assertNotEquals(newLegacyToken2, newLegacyToken);
assertNotEquals(newLegacyToken2, currentLegacyToken);
}
}
{ // but without any legacy token, the direct internal call remains but web UI depends on config
revokeAllToken(wc, user);
checkCombinationWithConfigAndMethodForLegacyTokenCreation(config, wc, user);
}
{// only the legacy token have impact on that capability
generateNewToken(wc, "user", "New token");
checkCombinationWithConfigAndMethodForLegacyTokenCreation(config, wc, user);
}
}
private void checkCombinationWithConfigAndMethodForLegacyTokenCreation(
ApiTokenPropertyConfiguration config, WebClient wc, User user
) throws Exception {
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
config.setCreationOfLegacyTokenEnabled(true);
{
{// change using web UI
changeLegacyToken(wc, "user", true);
String newLegacyToken = apiTokenProperty.getApiToken();
assertNotEquals(newLegacyToken, Messages.ApiTokenProperty_ChangeToken_CapabilityNotAllowed());
}
revokeLegacyToken(wc, user);
// always possible
changeTokenByDirectCall(apiTokenProperty);
revokeLegacyToken(wc, user);
}
revokeAllToken(wc, user);
config.setCreationOfLegacyTokenEnabled(false);
{
{// change not possible using web UI
changeLegacyToken(wc, "user", false);
String newLegacyToken = apiTokenProperty.getApiToken();
assertEquals(newLegacyToken, Messages.ApiTokenProperty_NoLegacyToken());
}
revokeLegacyToken(wc, user);
// always possible
changeTokenByDirectCall(apiTokenProperty);
revokeLegacyToken(wc, user);
}
}
private void changeTokenByDirectCall(ApiTokenProperty apiTokenProperty) throws Exception {
apiTokenProperty.changeApiToken();
String newLegacyToken = apiTokenProperty.getApiToken();
assertNotEquals(newLegacyToken, Messages.ApiTokenProperty_ChangeToken_CapabilityNotAllowed());
}
private void revokeAllToken(WebClient wc, User user) throws Exception {
revokeAllTokenUsingFilter(wc, user, it -> true);
}
private void revokeLegacyToken(WebClient wc, User user) throws Exception {
revokeAllTokenUsingFilter(wc, user, ApiTokenStore.HashedToken::isLegacy);
}
private void revokeAllTokenUsingFilter(WebClient wc, User user, Predicate<ApiTokenStore.HashedToken> filter) throws Exception {
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
List<String> uuidList = apiTokenProperty.getTokenStore().getTokenListSortedByName().stream()
.filter(filter)
.map(ApiTokenStore.HashedToken::getUuid)
.collect(Collectors.toList());
for(String uuid : uuidList){
revokeToken(wc, user.getId(), uuid);
}
}
private void revokeToken(WebClient wc, String login, String tokenUuid) throws Exception {
WebRequest request = new WebRequest(
new URL(j.getURL(), "user/" + login + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/revoke/?tokenUuid=" + tokenUuid),
HttpMethod.POST
);
Page p = wc.getPage(request);
assertEquals(200, p.getWebResponse().getStatusCode());
}
private void changeLegacyToken(WebClient wc, String login, boolean success) throws Exception {
WebRequest request = new WebRequest(
new URL(j.getURL(), "user/" + login + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/changeToken/"),
HttpMethod.POST
);
Page p = wc.getPage(request);
assertEquals(200, p.getWebResponse().getStatusCode());
if(success){
assertThat(p.getWebResponse().getContentAsString(), not(containsString(Messages.ApiTokenProperty_ChangeToken_CapabilityNotAllowed())));
}else{
assertThat(p.getWebResponse().getContentAsString(), containsString(Messages.ApiTokenProperty_ChangeToken_CapabilityNotAllowed()));
}
}
public static class GenerateNewTokenResponse {
public String tokenUuid;
public String tokenName;
public String tokenValue;
}
private GenerateNewTokenResponse generateNewToken(WebClient wc, String login, String tokenName) throws Exception {
WebRequest request = new WebRequest(
new URL(j.getURL(), "user/" + login + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/generateNewToken/?newTokenName=" + tokenName),
HttpMethod.POST
);
Page p = wc.getPage(request);
assertEquals(200, p.getWebResponse().getStatusCode());
String response = p.getWebResponse().getContentAsString();
JSONObject responseJson = JSONObject.fromObject(response);
Object result = responseJson.getJSONObject("data").toBean(GenerateNewTokenResponse.class);
return (GenerateNewTokenResponse) result;
}
// test no token are generated for new user with the global configuration set to false
}
......@@ -7,6 +7,8 @@ import hudson.ExtensionList;
import hudson.model.UnprotectedRootAction;
import hudson.model.User;
import hudson.util.HttpResponses;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
......@@ -45,6 +47,8 @@ public class BasicHeaderProcessorTest {
*/
@Test
public void testVariousWaysToCall() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
User foo = User.getById("foo", true);
User.getById("bar", true);
......@@ -112,6 +116,8 @@ public class BasicHeaderProcessorTest {
@Test
public void testAuthHeaderCaseInSensitive() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
User foo = User.get("foo");
wc = j.createWebClient();
......
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import hudson.model.User;
import jenkins.security.ApiTokenProperty;
import jenkins.security.Messages;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.JenkinsRule;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ApiTokenPropertyConfigurationTest {
@Rule
public JenkinsRule j = new JenkinsRule();
@Test
@Issue("JENKINS-32776")
public void newUserTokenConfiguration() throws Exception {
ApiTokenPropertyConfiguration config = ApiTokenPropertyConfiguration.get();
config.setTokenGenerationOnCreationEnabled(true);
{
User userWith = User.getById("userWith", true);
ApiTokenProperty withToken = userWith.getProperty(ApiTokenProperty.class);
assertTrue(withToken.hasLegacyToken());
assertEquals(1, withToken.getTokenList().size());
String tokenValue = withToken.getApiToken();
Assert.assertNotEquals(Messages.ApiTokenProperty_NoLegacyToken(), tokenValue);
}
config.setTokenGenerationOnCreationEnabled(false);
{
User userWithout = User.getById("userWithout", true);
ApiTokenProperty withoutToken = userWithout.getProperty(ApiTokenProperty.class);
assertFalse(withoutToken.hasLegacyToken());
assertEquals(0, withoutToken.getTokenList().size());
String tokenValue = withoutToken.getApiToken();
Assert.assertEquals(Messages.ApiTokenProperty_NoLegacyToken(), tokenValue);
}
}
}
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
import com.gargoylesoftware.htmlunit.HttpMethod;
import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.WebRequest;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.gargoylesoftware.htmlunit.html.HtmlSpan;
import com.gargoylesoftware.htmlunit.util.NameValuePair;
import com.gargoylesoftware.htmlunit.xml.XmlPage;
import hudson.model.User;
import jenkins.security.ApiTokenProperty;
import net.sf.json.JSONObject;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.JenkinsRule.WebClient;
import java.net.URL;
import java.util.Arrays;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.xml.HasXPath.hasXPath;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;
public class ApiTokenStatsTest {
@Rule
public JenkinsRule j = new JenkinsRule();
@Test
public void roundtrip() throws Exception {
j.jenkins.setCrumbIssuer(null);
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
User u = User.getById("foo", true);
ApiTokenProperty t = u.getProperty(ApiTokenProperty.class);
assertNotNull(t.getTokenStore());
assertNotNull(t.getTokenStats());
// test the authentication via Token
WebClient wc = j.createWebClient().withBasicCredentials(u.getId());
wc.getOptions().setThrowExceptionOnFailingStatusCode(false);
final String TOKEN_NAME = "New Token Name";
WebRequest request = new WebRequest(new URL(j.getURL() + "user/" + u.getId() + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/generateNewToken"), HttpMethod.POST);
request.setRequestParameters(Arrays.asList(new NameValuePair("newTokenName", TOKEN_NAME)));
Page page = wc.getPage(request);
assertEquals(200, page.getWebResponse().getStatusCode());
String responseContent = page.getWebResponse().getContentAsString();
JSONObject jsonObject = JSONObject.fromObject(responseContent);
JSONObject jsonData = jsonObject.getJSONObject("data");
String tokenName = jsonData.getString("tokenName");
String tokenValue = jsonData.getString("tokenValue");
String tokenUuid = jsonData.getString("tokenUuid");
assertEquals(TOKEN_NAME, tokenName);
WebClient restWc = j.createWebClient().withBasicCredentials(u.getId(), tokenValue);
checkUserIsConnected(restWc, u.getId());
HtmlPage config = wc.goTo(u.getUrl() + "/configure");
assertEquals(200, config.getWebResponse().getStatusCode());
assertThat(config.getWebResponse().getContentAsString(), containsString(tokenUuid));
assertThat(config.getWebResponse().getContentAsString(), containsString(tokenName));
final int NUM_CALL_WITH_TOKEN = 5;
// one is already done with checkUserIsConnected
for (int i = 1; i < NUM_CALL_WITH_TOKEN; i++) {
restWc.goToXml("whoAmI/api/xml");
}
HtmlPage configWithStats = wc.goTo(u.getUrl() + "/configure");
assertEquals(200, configWithStats.getWebResponse().getStatusCode());
HtmlSpan useCounterSpan = configWithStats.getDocumentElement().getOneHtmlElementByAttribute("span", "class", "token-use-counter");
assertThat(useCounterSpan.getTextContent(), containsString("" + NUM_CALL_WITH_TOKEN));
revokeToken(wc, u.getId(), tokenUuid);
// token is no more valid
checkUserIsNotConnected(restWc);
HtmlPage configWithoutToken = wc.goTo(u.getUrl() + "/configure");
assertEquals(200, configWithoutToken.getWebResponse().getStatusCode());
assertThat(configWithoutToken.getWebResponse().getContentAsString(), not(containsString(tokenUuid)));
assertThat(configWithoutToken.getWebResponse().getContentAsString(), not(containsString(tokenName)));
}
private void checkUserIsConnected(WebClient wc, String username) throws Exception {
XmlPage xmlPage = wc.goToXml("whoAmI/api/xml");
assertThat(xmlPage, hasXPath("//name", is(username)));
assertThat(xmlPage, hasXPath("//anonymous", is("false")));
assertThat(xmlPage, hasXPath("//authenticated", is("true")));
assertThat(xmlPage, hasXPath("//authority", is("authenticated")));
}
private void checkUserIsNotConnected(WebClient wc) throws Exception {
try {
wc.goToXml("whoAmI/api/xml");
fail();
} catch (FailingHttpStatusCodeException e) {
assertEquals(401, e.getStatusCode());
}
}
private void revokeToken(WebClient wc, String login, String tokenUuid) throws Exception {
WebRequest request = new WebRequest(
new URL(j.getURL(), "user/" + login + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/revoke/?tokenUuid=" + tokenUuid),
HttpMethod.POST
);
Page p = wc.getPage(request);
assertEquals(200, p.getWebResponse().getStatusCode());
}
}
/*
* The MIT License
*
* Copyright (c) 2011, CloudBees, Inc.
* Copyright (c) 2018, 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
......@@ -21,16 +21,16 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.security.ApiTokenProperty;
package jenkins.security.apitoken;
f=namespace(lib.FormTagLib)
f.advanced(title:_("Show API Token"), align:"left") {
f.entry(title: _('User ID')) {
f.readOnlyTextbox(value: my.id)
}
f.entry(title:_("API Token"), field:"apiToken") {
f.readOnlyTextbox(id:"apiToken") // TODO: need to figure out the way to do this without using ID.
public class ApiTokenTestHelper {
/**
* Reconfigure the instance to use legacy behavior
* When the jenkins-test-harness will support this, we will be able to remove this method.
*/
public static void enableLegacyBehavior(){
ApiTokenPropertyConfiguration config = ApiTokenPropertyConfiguration.get();
config.setTokenGenerationOnCreationEnabled(true);
config.setCreationOfLegacyTokenEnabled(true);
}
f.validateButton(title:_("Change API Token"),method:"changeToken")
}
/*
* The MIT License
*
* Copyright (c) 2018, 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.apitoken;
import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.html.DomNodeList;
import com.gargoylesoftware.htmlunit.html.HtmlAnchor;
import com.gargoylesoftware.htmlunit.html.HtmlButton;
import com.gargoylesoftware.htmlunit.html.HtmlDivision;
import com.gargoylesoftware.htmlunit.html.HtmlElement;
import com.gargoylesoftware.htmlunit.html.HtmlElementUtil;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import hudson.model.AdministrativeMonitor;
import hudson.model.User;
import jenkins.security.ApiTokenProperty;
import org.apache.commons.lang.StringUtils;
import org.hamcrest.Matchers;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
public class LegacyApiTokenAdministrativeMonitorTest {
@Rule
public JenkinsRule j = new JenkinsRule();
@Test
public void isActive() throws Exception {
ApiTokenPropertyConfiguration config = ApiTokenPropertyConfiguration.get();
config.setCreationOfLegacyTokenEnabled(true);
config.setTokenGenerationOnCreationEnabled(false);
// user created without legacy token
User user = User.getById("user", true);
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
assertFalse(apiTokenProperty.hasLegacyToken());
LegacyApiTokenAdministrativeMonitor monitor = j.jenkins.getExtensionList(AdministrativeMonitor.class).get(LegacyApiTokenAdministrativeMonitor.class);
assertFalse(monitor.isActivated());
ApiTokenStore.TokenUuidAndPlainValue tokenInfo = apiTokenProperty.getTokenStore().generateNewToken("Not Legacy");
// "new" token does not trigger the monitor
assertFalse(monitor.isActivated());
apiTokenProperty.getTokenStore().revokeToken(tokenInfo.tokenUuid);
assertFalse(monitor.isActivated());
apiTokenProperty.changeApiToken();
assertTrue(monitor.isActivated());
}
@Test
public void listOfUserWithLegacyTokenIsCorrect() throws Exception {
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
ApiTokenPropertyConfiguration config = ApiTokenPropertyConfiguration.get();
config.setCreationOfLegacyTokenEnabled(true);
config.setTokenGenerationOnCreationEnabled(false);
LegacyApiTokenAdministrativeMonitor monitor = j.jenkins.getExtensionList(AdministrativeMonitor.class).get(LegacyApiTokenAdministrativeMonitor.class);
JenkinsRule.WebClient wc = j.createWebClient();
int numToken = 0;
int numFreshToken = 0;
int numRecentToken = 0;
{// no user
checkUserWithLegacyTokenListIsEmpty(wc, monitor);
}
{// with user without any token
User user = User.getById("user", true);
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
assertFalse(apiTokenProperty.hasLegacyToken());
checkUserWithLegacyTokenListIsEmpty(wc, monitor);
}
{// with user with token but without legacy token
User user = User.getById("user", true);
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
assertFalse(apiTokenProperty.hasLegacyToken());
apiTokenProperty.getTokenStore().generateNewToken("Not legacy");
checkUserWithLegacyTokenListIsEmpty(wc, monitor);
checkUserWithLegacyTokenListHasSizeOf(wc, monitor, numToken, numFreshToken, numRecentToken);
}
{// one user with just legacy token
createUserWithToken(true, false, false);
numToken++;
checkUserWithLegacyTokenListHasSizeOf(wc, monitor, numToken, numFreshToken, numRecentToken);
}
{// one user with a fresh token
// fresh = created after the last use of the legacy token (or its creation)
createUserWithToken(true, true, false);
numToken++;
numFreshToken++;
checkUserWithLegacyTokenListHasSizeOf(wc, monitor, numToken, numFreshToken, numRecentToken);
}
{// one user with a recent token (that is not fresh)
// recent = last use after the last use of the legacy token (or its creation)
createUserWithToken(true, false, true);
numToken++;
numRecentToken++;
checkUserWithLegacyTokenListHasSizeOf(wc, monitor, numToken, numFreshToken, numRecentToken);
}
{// one user with a fresh + recent token
createUserWithToken(true, true, true);
numToken++;
numFreshToken++;
numRecentToken++;
checkUserWithLegacyTokenListHasSizeOf(wc, monitor, numToken, numFreshToken, numRecentToken);
}
}
@Test
public void monitorManagePageFilterAreWorking() throws Exception {
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
ApiTokenPropertyConfiguration config = ApiTokenPropertyConfiguration.get();
config.setCreationOfLegacyTokenEnabled(true);
config.setTokenGenerationOnCreationEnabled(false);
// create 1 user with legacy, 2 with fresh, 3 with recent and 4 with fresh+recent
prepareUsersForFilters();
LegacyApiTokenAdministrativeMonitor monitor = j.jenkins.getExtensionList(AdministrativeMonitor.class).get(LegacyApiTokenAdministrativeMonitor.class);
JenkinsRule.WebClient wc = j.createWebClient();
HtmlPage page = wc.goTo(monitor.getUrl() + "/manage");
checkUserWithLegacyTokenListHasSizeOf(page, 1 + 2 + 3 + 4, 2 + 4, 3 + 4);
HtmlElement document = page.getDocumentElement();
HtmlElement filterDiv = document.getOneHtmlElementByAttribute("div", "class", "selection-panel");
DomNodeList<HtmlElement> filters = filterDiv.getElementsByTagName("a");
assertEquals(3, filters.size());
HtmlAnchor filterAll = (HtmlAnchor) filters.get(0);
HtmlAnchor filterOnlyFresh = (HtmlAnchor) filters.get(1);
HtmlAnchor filterOnlyRecent = (HtmlAnchor) filters.get(2);
{ // test just the filterAll
checkNumberOfSelectedTr(document, 0);
HtmlElementUtil.click(filterAll);
checkNumberOfSelectedTr(document, 1 + 2 + 3 + 4);
HtmlElementUtil.click(filterAll);
checkNumberOfSelectedTr(document, 0);
}
{ // test just the filterOnlyFresh
HtmlElementUtil.click(filterOnlyFresh);
checkNumberOfSelectedTr(document, 2 + 4);
HtmlElementUtil.click(filterOnlyFresh);
checkNumberOfSelectedTr(document, 0);
}
{ // test just the filterOnlyRecent
HtmlElementUtil.click(filterOnlyRecent);
checkNumberOfSelectedTr(document, 3 + 4);
HtmlElementUtil.click(filterOnlyRecent);
checkNumberOfSelectedTr(document, 0);
}
{ // test interaction
HtmlElementUtil.click(filterOnlyFresh);
checkNumberOfSelectedTr(document, 2 + 4);
// the 4 (recent+fresh) are still selected
HtmlElementUtil.click(filterOnlyRecent);
checkNumberOfSelectedTr(document, 3 + 4);
HtmlElementUtil.click(filterAll);
checkNumberOfSelectedTr(document, 1 + 2 + 3 + 4);
}
}
private void prepareUsersForFilters() throws Exception {
// 1 user with just legacy token
createUserWithToken(true, false, false);
// 2 users fresh but not recent
createUserWithToken(true, true, false);
createUserWithToken(true, true, false);
// 3 users recent but not fresh
createUserWithToken(true, false, true);
createUserWithToken(true, false, true);
createUserWithToken(true, false, true);
// 4 users fresh and recent
createUserWithToken(true, true, true);
createUserWithToken(true, true, true);
createUserWithToken(true, true, true);
createUserWithToken(true, true, true);
}
private void checkNumberOfSelectedTr(HtmlElement document, int expectedCount) {
DomNodeList<HtmlElement> trList = document.getElementsByTagName("tr");
long amount = trList.stream().filter(htmlElement -> htmlElement.getAttribute("class").contains("selected")).count();
assertEquals(expectedCount, amount);
}
@Test
public void monitorManagePageCanRevokeToken() throws Exception {
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
ApiTokenPropertyConfiguration config = ApiTokenPropertyConfiguration.get();
config.setCreationOfLegacyTokenEnabled(true);
config.setTokenGenerationOnCreationEnabled(false);
// create 1 user with legacy, 2 with fresh, 3 with recent and 4 with fresh+recent
prepareUsersForFilters();
LegacyApiTokenAdministrativeMonitor monitor = j.jenkins.getExtensionList(AdministrativeMonitor.class).get(LegacyApiTokenAdministrativeMonitor.class);
assertTrue(monitor.isActivated());
JenkinsRule.WebClient wc = j.createWebClient();
HtmlPage page = wc.goTo(monitor.getUrl() + "/manage");
checkUserWithLegacyTokenListHasSizeOf(page, 1 + 2 + 3 + 4, 2 + 4, 3 + 4);
{// select 2
HtmlAnchor filterOnlyFresh = getFilterByIndex(page, 1);
HtmlElementUtil.click(filterOnlyFresh);
}
// revoke them
HtmlButton revokeSelected = getRevokeSelected(page);
HtmlElementUtil.click(revokeSelected);
HtmlPage newPage = checkUserWithLegacyTokenListHasSizeOf(wc, monitor, 1 + 3, 0, 3);
assertTrue(monitor.isActivated());
{// select 1 + 3
HtmlAnchor filterAll = getFilterByIndex(newPage, 0);
HtmlElementUtil.click(filterAll);
}
// revoke them
revokeSelected = getRevokeSelected(newPage);
HtmlElementUtil.click(revokeSelected);
checkUserWithLegacyTokenListHasSizeOf(wc, monitor, 0, 0, 0);
assertFalse(monitor.isActivated());
}
private HtmlAnchor getFilterByIndex(HtmlPage page, int index) {
HtmlElement document = page.getDocumentElement();
HtmlDivision filterDiv = document.getOneHtmlElementByAttribute("div", "class", "selection-panel");
DomNodeList<HtmlElement> filters = filterDiv.getElementsByTagName("a");
assertEquals(3, filters.size());
HtmlAnchor filter = (HtmlAnchor) filters.get(index);
assertNotNull(filter);
return filter;
}
private HtmlButton getRevokeSelected(HtmlPage page) {
HtmlElement document = page.getDocumentElement();
HtmlButton revokeSelected = document.getOneHtmlElementByAttribute("button", "class", "action-revoke-selected");
assertNotNull(revokeSelected);
return revokeSelected;
}
private void checkUserWithLegacyTokenListIsEmpty(JenkinsRule.WebClient wc, LegacyApiTokenAdministrativeMonitor monitor) throws Exception {
HtmlPage page = wc.goTo(monitor.getUrl() + "/manage");
String pageContent = page.getWebResponse().getContentAsString();
assertThat(pageContent, Matchers.containsString("no-token-line"));
}
private HtmlPage checkUserWithLegacyTokenListHasSizeOf(
JenkinsRule.WebClient wc, LegacyApiTokenAdministrativeMonitor monitor,
int countOfToken, int countOfFreshToken, int countOfRecentToken) throws Exception {
HtmlPage page = wc.goTo(monitor.getUrl() + "/manage");
checkUserWithLegacyTokenListHasSizeOf(page, countOfToken, countOfFreshToken, countOfRecentToken);
return page;
}
private void checkUserWithLegacyTokenListHasSizeOf(
Page page,
int countOfToken, int countOfFreshToken, int countOfRecentToken) throws Exception {
String pageContent = page.getWebResponse().getContentAsString();
int actualCountOfToken = StringUtils.countMatches(pageContent, "token-to-revoke");
assertEquals(countOfToken, actualCountOfToken);
int actualCountOfFreshToken = StringUtils.countMatches(pageContent, "fresh-token");
assertEquals(countOfFreshToken, actualCountOfFreshToken);
int actualCountOfRecentToken = StringUtils.countMatches(pageContent, "recent-token");
assertEquals(countOfRecentToken, actualCountOfRecentToken);
}
private void simulateUseOfLegacyToken(User user) throws Exception {
JenkinsRule.WebClient wc = j.createWebClient();
wc.withBasicApiToken(user);
wc.goTo("whoAmI/api/xml", null);
}
private void simulateUseOfToken(User user, String tokenPlainValue) throws Exception {
JenkinsRule.WebClient wc = j.createWebClient();
wc.withBasicCredentials(user.getId(), tokenPlainValue);
wc.goTo("whoAmI/api/xml", null);
}
private int nextId = 0;
private void createUserWithToken(boolean legacy, boolean fresh, boolean recent) throws Exception {
User user = User.getById(String.format("user %b %b %b %d", legacy, fresh, recent, nextId++), true);
if (!legacy) {
return ;
}
ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class);
apiTokenProperty.changeApiToken();
if (fresh) {
if (recent) {
simulateUseOfLegacyToken(user);
Thread.sleep(1);
ApiTokenStore.TokenUuidAndPlainValue tokenInfo = apiTokenProperty.getTokenStore().generateNewToken("Fresh and recent token");
simulateUseOfToken(user, tokenInfo.plainValue);
} else {
simulateUseOfLegacyToken(user);
Thread.sleep(1);
apiTokenProperty.getTokenStore().generateNewToken("Fresh token");
}
} else {
if (recent) {
ApiTokenStore.TokenUuidAndPlainValue tokenInfo = apiTokenProperty.getTokenStore().generateNewToken("Recent token");
Thread.sleep(1);
simulateUseOfLegacyToken(user);
Thread.sleep(1);
simulateUseOfToken(user, tokenInfo.plainValue);
}
//else: no other token to generate
}
}
}
......@@ -40,6 +40,8 @@ import java.util.Locale;
import java.util.regex.Pattern;
import jenkins.model.Jenkins;
import jenkins.security.apitoken.ApiTokenPropertyConfiguration;
import jenkins.security.apitoken.ApiTokenTestHelper;
import org.acegisecurity.Authentication;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
......@@ -95,6 +97,8 @@ public class PasswordTest {
@Issue({"SECURITY-266", "SECURITY-304"})
@Test
public void testExposedCiphertext() throws Exception {
ApiTokenTestHelper.enableLegacyBehavior();
boolean saveEnabled = Item.EXTENDED_READ.getEnabled();
Item.EXTENDED_READ.setEnabled(true);
try {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册