IssuerAcceptabilityHandlerImpl.java
/*
* Copyright (C) 2023 jtalbut
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package uk.co.spudsoft.jwtvalidatorvertx.impl;
import com.google.common.base.Strings;
import java.io.File;
import java.nio.file.Files;
import java.time.Duration;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.co.spudsoft.jwtvalidatorvertx.IssuerAcceptabilityHandler;
/**
* The standard IssuerAcceptabilityHandler.
*
* Provides two approaches, which can be used in isolation or together:
* <ol>
* <li> A list of acceptable issuers can be provided in a file.
* The file can be changed whilst the system is up, but the path to the file is fixed.
* It is recommended that the file be updated atomically (e.g. by changing a soft link).
* <li> A list of regular expressions can be provided.
* Each regular expression will be checked, one at a time.
* </ol>
*
* The use of a file is generally more secure, but there are some situations in which a small number of regular expressions can be useful.
*
* Each line in the file is trimmed before adding to an internal Set, so leading and trailing whitespace is removed (and the line ending of the file is irrelevant).
* It is strongly recommended that each line of the file be an https URL.
*
* @author yaytay
*/
public class IssuerAcceptabilityHandlerImpl implements IssuerAcceptabilityHandler {
private static final Logger logger = LoggerFactory.getLogger(IssuerAcceptabilityHandlerImpl.class);
private final List<Pattern> acceptableIssuerRegexes;
private final File acceptableIssuersFile;
private final long pollPeriodMs;
private final Object lock = new Object();
private long lastFileCheck = 0;
private Set<String> acceptableIssuers = Collections.emptySet();
private long fileLastModified = 0;
@Override
public void validate() throws IllegalArgumentException {
if (acceptableIssuerRegexes.isEmpty() && acceptableIssuersFile == null) {
throw new IllegalArgumentException("No acceptable issuers configured - neither regular expressions nor file configured");
}
}
/**
* Constructor.
* @param acceptableIssuerRegexes The List of regular expressions (as Strings) that are acceptable.
* @param acceptableIssuersFile The path to a file that contains valid issuers, one per line.
* @param pollPeriod The time period between file checks (the check just looks at the last modified time, so make this about a minute).
*/
public IssuerAcceptabilityHandlerImpl(List<String> acceptableIssuerRegexes, String acceptableIssuersFile, Duration pollPeriod) {
this.acceptableIssuerRegexes = acceptableIssuerRegexes == null ? Collections.emptyList() : acceptableIssuerRegexes.stream()
.map(re -> {
if (re == null || re.isBlank()) {
logger.warn("Null or empty pattern cannot be used: ", re);
return null;
}
try {
Pattern pattern = Pattern.compile(re);
logger.trace("Compiled acceptable issuer regex as {}", pattern.pattern());
return pattern;
} catch (Throwable ex) {
logger.warn("The pattern \"{}\" cannot be compiled: ", re, ex);
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
this.acceptableIssuersFile = Strings.isNullOrEmpty(acceptableIssuersFile) ? null : new File(acceptableIssuersFile);
this.pollPeriodMs = pollPeriod.toMillis();
}
@Override
public boolean isAcceptable(String issuer) {
Set<String> localAcceptableIssuers;
boolean shouldUpdate = false;
long now;
if (Strings.isNullOrEmpty(issuer)) {
logger.warn("Invalid issuer: {}", (issuer == null ? "<null>" : "<blank>"));
return false;
}
synchronized (lock) {
now = System.currentTimeMillis();
if (lastFileCheck + pollPeriodMs < now) {
lastFileCheck = now;
shouldUpdate = true;
}
localAcceptableIssuers = acceptableIssuers;
}
if (shouldUpdate) {
checkFile(now);
synchronized (lock) {
localAcceptableIssuers = acceptableIssuers;
}
}
if (localAcceptableIssuers.contains(issuer)) {
return true;
}
for (Pattern acceptableIssuer : acceptableIssuerRegexes) {
if (acceptableIssuer.matcher(issuer).matches()) {
return true;
}
}
return false;
}
private void checkFile(long now) {
if (acceptableIssuersFile == null) {
return ;
}
try {
if (acceptableIssuersFile.isFile()) {
long lastModNew = acceptableIssuersFile.lastModified();
if (lastModNew != fileLastModified && lastModNew + pollPeriodMs < now) {
fileLastModified = lastModNew;
Set<String> newSet = new HashSet<>();
try (Stream<String> stream = Files.lines(acceptableIssuersFile.toPath())) {
stream
.forEach(l -> {
if (l != null) {
l = l.trim();
if (!l.isEmpty()) {
newSet.add(l);
}
}
})
;
}
synchronized (lock) {
acceptableIssuers = newSet;
}
}
} else {
synchronized (lock) {
acceptableIssuers = Collections.emptySet();
}
}
} catch (Throwable ex) {
logger.error("Error loading acceptable issuers file: ", ex);
}
}
}