KNOX-2377 - Address potential loss of token state (#345)
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
index 4f2e18c..24d3fb9 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
@@ -21,7 +21,11 @@
import org.apache.knox.gateway.services.security.AliasService;
import org.apache.knox.gateway.services.security.AliasServiceException;
import org.apache.knox.gateway.services.security.token.UnknownTokenException;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.apache.knox.gateway.services.token.impl.state.TokenStateJournalFactory;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -49,6 +53,8 @@
private final List<TokenState> unpersistedState = new ArrayList<>();
+ private TokenStateJournal journal;
+
public void setAliasService(AliasService aliasService) {
this.aliasService = aliasService;
}
@@ -59,6 +65,32 @@
if (aliasService == null) {
throw new ServiceLifecycleException("The required AliasService reference has not been set.");
}
+
+ try {
+ // Initialize the token state journal
+ journal = TokenStateJournalFactory.create(config);
+
+ // Load any persisted journal entries, and add them to the unpersisted state collection
+ List<JournalEntry> entries = journal.get();
+ for (JournalEntry entry : entries) {
+ String id = entry.getTokenId();
+ long issueTime = Long.parseLong(entry.getIssueTime());
+ long expiration = Long.parseLong(entry.getExpiration());
+ long maxLifetime = Long.parseLong(entry.getMaxLifetime());
+
+ // Add the token state to memory
+ super.addToken(id, issueTime, expiration, maxLifetime);
+
+ synchronized (unpersistedState) {
+ // The max lifetime entry is added by way of the call to super.addToken(),
+ // so only need to add the expiration entry here.
+ unpersistedState.add(new TokenExpiration(id, expiration));
+ }
+ }
+ } catch (IOException e) {
+ throw new ServiceLifecycleException("Failed to load persisted state from the token state journal", e);
+ }
+
statePersistenceInterval = config.getKnoxTokenStateAliasPersistenceInterval();
if (statePersistenceInterval > 0) {
statePersistenceScheduler = Executors.newScheduledThreadPool(1);
@@ -69,7 +101,7 @@
public void start() throws ServiceLifecycleException {
super.start();
if (statePersistenceScheduler != null) {
- // Run token eviction task at configured interval
+ // Run token persistence task at configured interval
statePersistenceScheduler.scheduleAtFixedRate(this::persistTokenState,
statePersistenceInterval,
statePersistenceInterval,
@@ -83,6 +115,9 @@
if (statePersistenceScheduler != null) {
statePersistenceScheduler.shutdown();
}
+
+ // Make an attempt to persist any unpersisted token state before shutting down
+ persistTokenState();
}
protected void persistTokenState() {
@@ -114,6 +149,12 @@
aliasService.addAliasesForCluster(AliasService.NO_CLUSTER_NAME, aliases);
for (String tokenId : tokenIds) {
log.createdTokenStateAliases(tokenId);
+ // After the aliases have been successfully persisted, remove their associated state from the journal
+ try {
+ journal.remove(tokenId);
+ } catch (IOException e) {
+ log.failedToRemoveJournalEntry(tokenId, e);
+ }
}
} catch (AliasServiceException e) {
log.failedToCreateTokenStateAliases(e);
@@ -134,6 +175,12 @@
synchronized (unpersistedState) {
unpersistedState.add(new TokenExpiration(tokenId, expiration));
}
+
+ try {
+ journal.add(tokenId, issueTime, expiration, maxLifetimeDuration);
+ } catch (IOException e) {
+ log.failedToAddJournalEntry(tokenId, e);
+ }
}
@Override
@@ -166,8 +213,10 @@
@Override
public long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException {
- // Check the in-memory collection first and return immediately if associated record found there
+ // Check the in-memory collection first, to avoid costly keystore access when possible
try {
+ // If the token identifier is valid, and the associated state is available from the in-memory cache, then
+ // return the expiration from there.
return super.getTokenExpiration(tokenId, validate);
} catch (UnknownTokenException e) {
// It's not in memory
@@ -177,13 +226,13 @@
validateToken(tokenId);
}
- // If there is no associated record in the in-memory collection, proceed to check the alias service
+ // If there is no associated state in the in-memory cache, proceed to check the alias service
long expiration = 0;
try {
char[] expStr = aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId);
if (expStr != null) {
expiration = Long.parseLong(new String(expStr));
- // Update the in-memory record
+ // Update the in-memory cache to avoid subsequent keystore look-ups for the same state
super.updateExpiration(tokenId, expiration);
}
} catch (Exception e) {
@@ -215,6 +264,20 @@
@Override
protected void removeTokens(Set<String> tokenIds) throws UnknownTokenException {
+
+ // If any of the token IDs is represented among the unpersisted state, remove the associated state
+ synchronized (unpersistedState) {
+ List<TokenState> unpersistedToRemove = new ArrayList<>();
+ for (TokenState state : unpersistedState) {
+ if (tokenIds.contains(state.getTokenId())) {
+ unpersistedToRemove.add(state);
+ }
+ }
+ for (TokenState state : unpersistedToRemove) {
+ unpersistedState.remove(state);
+ }
+ }
+
// Add the max lifetime aliases to the list of aliases to remove
Set<String> aliasesToRemove = new HashSet<>(tokenIds);
for (String tokenId : tokenIds) {
@@ -267,7 +330,7 @@
return (tokenIds != null ? tokenIds : Collections.emptyList());
}
- private interface TokenState {
+ interface TokenState {
String getTokenId();
String getAlias();
String getAliasValue();
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
index a85f3bf..a3d7502 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
@@ -97,4 +97,34 @@
@Message(level = MessageLevel.INFO, text = "Removed token state aliases for {0}")
void removedTokenStateAliases(String tokenId);
+ @Message(level = MessageLevel.INFO, text = "Loading peristed token state journal entries")
+ void loadingPersistedJournalEntries();
+
+ @Message(level = MessageLevel.DEBUG, text = "Loaded peristed token state journal entry for {0}")
+ void loadedPersistedJournalEntry(String tokenId);
+
+ @Message(level = MessageLevel.ERROR, text = "The peristed token state journal entry {0} is empty")
+ void emptyJournalEntry(String journalEntryName);
+
+ @Message(level = MessageLevel.INFO, text = "Added token state journal entry for {0}")
+ void addedJournalEntry(String tokenId);
+
+ @Message(level = MessageLevel.INFO, text = "Removed token state journal entry for {0}")
+ void removedJournalEntry(String tokenId);
+
+ @Message(level = MessageLevel.INFO, text = "Token state journal entry not found for {0}")
+ void journalEntryNotFound(String tokenId);
+
+ @Message(level = MessageLevel.DEBUG, text = "Persisting token state journal entry as {0}")
+ void persistingJournalEntry(String journalEntryFilename);
+
+ @Message(level = MessageLevel.ERROR, text = "Failed to persisting token state journal entry for {0} : {1}")
+ void failedToPersistJournalEntry(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+ @Message(level = MessageLevel.ERROR, text = "Failed to add a token state journal entry for {0} : {1}")
+ void failedToAddJournalEntry(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+ @Message(level = MessageLevel.ERROR, text = "Failed to remove the token state journal entry for {0} : {1}")
+ void failedToRemoveJournalEntry(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournal.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournal.java
new file mode 100644
index 0000000..31a8954
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournal.java
@@ -0,0 +1,221 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.apache.knox.gateway.services.token.impl.TokenStateServiceMessages;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Base class for TokenStateJournal implementations that employ files for persistence.
+ */
+abstract class FileTokenStateJournal implements TokenStateJournal {
+
+ protected static final int INDEX_TOKEN_ID = 0;
+ protected static final int INDEX_ISSUE_TIME = 1;
+ protected static final int INDEX_EXPIRATION = 2;
+ protected static final int INDEX_MAX_LIFETIME = 3;
+
+ protected static final TokenStateServiceMessages log = MessagesFactory.get(TokenStateServiceMessages.class);
+
+ // The name of the journal directory
+ protected static final String JOURNAL_DIR_NAME = "token-state";
+
+ /**
+ * The journal directory path
+ */
+ protected final Path journalDir;
+
+ protected FileTokenStateJournal(GatewayConfig config) throws IOException {
+ journalDir = Paths.get(config.getGatewaySecurityDir(), JOURNAL_DIR_NAME);
+ if (!Files.exists(journalDir)) {
+ Files.createDirectories(journalDir);
+ }
+ }
+
+ @Override
+ public abstract void add(String tokenId, long issueTime, long expiration, long maxLifetime) throws IOException;
+
+ @Override
+ public void add(JournalEntry entry) throws IOException {
+ add(Collections.singletonList(entry));
+ }
+
+ @Override
+ public abstract void add(List<JournalEntry> entries) throws IOException;
+
+ @Override
+ public List<JournalEntry> get() throws IOException {
+ return loadJournal();
+ }
+
+ @Override
+ public abstract JournalEntry get(String tokenId) throws IOException;
+
+ @Override
+ public void remove(final String tokenId) throws IOException {
+ remove(Collections.singleton(tokenId));
+ }
+
+ @Override
+ public abstract void remove(Collection<String> tokenIds) throws IOException;
+
+ @Override
+ public void remove(final JournalEntry entry) throws IOException {
+ remove(entry.getTokenId());
+ }
+
+ protected abstract List<JournalEntry> loadJournal() throws IOException;
+
+ protected List<FileJournalEntry> loadJournal(FileChannel channel) throws IOException {
+ List<FileJournalEntry> entries = new ArrayList<>();
+
+ try (InputStream input = Channels.newInputStream(channel)) {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ entries.add(FileJournalEntry.parse(line));
+ }
+ }
+
+ return entries;
+ }
+
+ /**
+ * Parse the String representation of an entry.
+ *
+ * @param entry A journal file entry line
+ *
+ * @return A FileJournalEntry object created from the specified entry.
+ */
+ protected FileJournalEntry parse(final String entry) {
+ return FileJournalEntry.parse(entry);
+ }
+
+ /**
+ * A JournalEntry implementation for File-based TokenStateJournal implementations
+ */
+ static final class FileJournalEntry implements JournalEntry {
+ private final String tokenId;
+ private final String issueTime;
+ private final String expiration;
+ private final String maxLifetime;
+
+ FileJournalEntry(final String tokenId, long issueTime, long expiration, long maxLifetime) {
+ this(tokenId, String.valueOf(issueTime), String.valueOf(expiration), String.valueOf(maxLifetime));
+ }
+
+ FileJournalEntry(final String tokenId,
+ final String issueTime,
+ final String expiration,
+ final String maxLifetime) {
+ this.tokenId = tokenId;
+ this.issueTime = issueTime;
+ this.expiration = expiration;
+ this.maxLifetime = maxLifetime;
+ }
+
+ @Override
+ public String getTokenId() {
+ return tokenId;
+ }
+
+ @Override
+ public String getIssueTime() {
+ return issueTime;
+ }
+
+ @Override
+ public String getExpiration() {
+ return expiration;
+ }
+
+ @Override
+ public String getMaxLifetime() {
+ return maxLifetime;
+ }
+
+ @Override
+ public String toString() {
+ String[] elements = new String[4];
+
+ elements[INDEX_TOKEN_ID] = getTokenId();
+
+ String issueTime = getIssueTime();
+ elements[INDEX_ISSUE_TIME] = (issueTime != null) ? issueTime : "";
+
+ String expiration = getExpiration();
+ elements[INDEX_EXPIRATION] = (expiration != null) ? expiration : "";
+
+ String maxLifetime = getMaxLifetime();
+ elements[INDEX_MAX_LIFETIME] = (maxLifetime != null) ? maxLifetime : "";
+
+ return String.format(Locale.ROOT,
+ "%s,%s,%s,%s",
+ elements[INDEX_TOKEN_ID],
+ elements[INDEX_ISSUE_TIME],
+ elements[INDEX_EXPIRATION],
+ elements[INDEX_MAX_LIFETIME]);
+ }
+
+ /**
+ * Parse the String representation of an entry.
+ *
+ * @param entry A journal file entry line
+ *
+ * @return A FileJournalEntry object created from the specified entry.
+ */
+ static FileJournalEntry parse(final String entry) {
+ String[] elements = entry.split(",");
+ if (elements.length < 4) {
+ throw new IllegalArgumentException("Invalid journal entry: " + entry);
+ }
+
+ String tokenId = elements[INDEX_TOKEN_ID].trim();
+ String issueTime = elements[INDEX_ISSUE_TIME].trim();
+ String expiration = elements[INDEX_EXPIRATION].trim();
+ String maxLifetime = elements[INDEX_MAX_LIFETIME].trim();
+
+ return new FileJournalEntry(tokenId.isEmpty() ? null : tokenId,
+ issueTime.isEmpty() ? null : issueTime,
+ expiration.isEmpty() ? null : expiration,
+ maxLifetime.isEmpty() ? null : maxLifetime);
+ }
+
+ }
+
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournal.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournal.java
new file mode 100644
index 0000000..dfdd1e1
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournal.java
@@ -0,0 +1,142 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A TokenStateJournal implementation that manages separate files for token state.
+ */
+class MultiFileTokenStateJournal extends FileTokenStateJournal {
+
+ // File extension for journal entry files
+ static final String ENTRY_FILE_EXT = ".ts";
+
+ // Filter used when listing all journal entry files in the journal directory
+ static final String ENTRY_FILE_EXT_FILTER = "*" + ENTRY_FILE_EXT;
+
+ MultiFileTokenStateJournal(GatewayConfig config) throws IOException {
+ super(config);
+ }
+
+ @Override
+ public void add(final String tokenId, long issueTime, long expiration, long maxLifetime) throws IOException {
+ add(Collections.singletonList(new FileJournalEntry(tokenId, issueTime, expiration, maxLifetime)));
+ }
+
+ @Override
+ public void add(final List<JournalEntry> entries) throws IOException {
+ // Persist each journal entry as an individual file in the journal directory
+ for (JournalEntry entry : entries) {
+ final Path entryFile = journalDir.resolve(entry.getTokenId() + ENTRY_FILE_EXT);
+ log.persistingJournalEntry(entryFile.toString());
+ try (FileChannel fileChannel = FileChannel.open(entryFile, StandardOpenOption.WRITE,
+ StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ fileChannel.lock();
+ try (OutputStream out = Channels.newOutputStream(fileChannel)) {
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
+ writer.write(entry.toString());
+ writer.newLine();
+ writer.flush();
+ }
+ log.addedJournalEntry(entry.getTokenId());
+ } catch (IOException e){
+ log.failedToPersistJournalEntry(entry.getTokenId(), e);
+ throw e;
+ }
+ }
+ }
+
+ @Override
+ public JournalEntry get(final String tokenId) throws IOException {
+ JournalEntry result = null;
+
+ Path entryFilePath = journalDir.resolve(tokenId + ENTRY_FILE_EXT);
+ if (Files.exists(entryFilePath)) {
+ try (FileChannel fileChannel = FileChannel.open(entryFilePath, StandardOpenOption.READ)) {
+ fileChannel.lock(0L, Long.MAX_VALUE, true);
+ List<FileJournalEntry> entries = loadJournal(fileChannel);
+ if (entries.isEmpty()) {
+ log.journalEntryNotFound(tokenId);
+ } else {
+ result = entries.get(0);
+ }
+ }
+ } else {
+ log.journalEntryNotFound(tokenId);
+ }
+
+ return result;
+ }
+
+ @Override
+ public void remove(final Collection<String> tokenIds) throws IOException {
+ // Remove the journal entry files corresponding to the specified token identifiers
+ for (String tokenId : tokenIds) {
+ Path entryFilePath = journalDir.resolve(tokenId + ENTRY_FILE_EXT);
+ if (Files.exists(entryFilePath)) {
+ Files.delete(entryFilePath);
+ log.removedJournalEntry(tokenId);
+ }
+ }
+ }
+
+ @Override
+ protected List<JournalEntry> loadJournal() throws IOException {
+ List<JournalEntry> entries = new ArrayList<>();
+
+ // List all the journal entry files in the directory, and create journal entries for them
+ if (Files.exists(journalDir)) {
+ log.loadingPersistedJournalEntries();
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(journalDir, ENTRY_FILE_EXT_FILTER)) {
+ for (Path entryFilePath : stream ) {
+ try (FileChannel fileChannel = FileChannel.open(entryFilePath, StandardOpenOption.READ)) {
+ fileChannel.lock(0L, Long.MAX_VALUE, true);
+ entries.addAll(loadJournal(fileChannel));
+ if (entries.isEmpty()) {
+ log.emptyJournalEntry(entryFilePath.toString());
+ } else {
+ // Should only be a single entry for this implementation
+ log.loadedPersistedJournalEntry(entries.get(0).getTokenId());
+ }
+ }
+ }
+ }
+ }
+
+ return entries;
+ }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/TokenStateJournalFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/TokenStateJournalFactory.java
new file mode 100644
index 0000000..2f3d43b
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/TokenStateJournalFactory.java
@@ -0,0 +1,32 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+
+import java.io.IOException;
+
+public class TokenStateJournalFactory {
+
+ public static TokenStateJournal create(GatewayConfig config) throws IOException {
+ return new MultiFileTokenStateJournal(config);
+ }
+
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/JournalEntry.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/JournalEntry.java
new file mode 100644
index 0000000..d520f45
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/JournalEntry.java
@@ -0,0 +1,51 @@
+/*
+ *
+ * * Licensed to the Apache Software Foundation (ASF) under one or more
+ * * contributor license agreements. See the NOTICE file distributed with this
+ * * work for additional information regarding copyright ownership. The ASF
+ * * licenses this file to you under the Apache License, Version 2.0 (the
+ * * "License"); you may not use this file except in compliance with the License.
+ * * You may obtain a copy of the License at
+ * *
+ * * http://www.apache.org/licenses/LICENSE-2.0
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * * License for the specific language governing permissions and limitations under
+ * * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.state;
+
+/**
+ * An entry in the TokenStateJournal
+ */
+public interface JournalEntry {
+
+ /**
+ *
+ * @return The unique token identifier for which this entry is defined.
+ */
+ String getTokenId();
+
+ /**
+ *
+ * @return The token's issue time (milliseconds since the epoch) as a String.
+ */
+ String getIssueTime();
+
+ /**
+ *
+ * @return The token's expiration time (milliseconds since the epoch) as a String.
+ */
+ String getExpiration();
+
+ /**
+ * The token's maximum allowed lifetime, beyond which its expiration cannot be extended,
+ * (milliseconds since the epoch) as a String.
+ *
+ * @return The token's maximum allowed lifetime
+ */
+ String getMaxLifetime();
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/TokenStateJournal.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/TokenStateJournal.java
new file mode 100644
index 0000000..f9aa01b
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/TokenStateJournal.java
@@ -0,0 +1,92 @@
+/*
+ *
+ * * Licensed to the Apache Software Foundation (ASF) under one or more
+ * * contributor license agreements. See the NOTICE file distributed with this
+ * * work for additional information regarding copyright ownership. The ASF
+ * * licenses this file to you under the Apache License, Version 2.0 (the
+ * * "License"); you may not use this file except in compliance with the License.
+ * * You may obtain a copy of the License at
+ * *
+ * * http://www.apache.org/licenses/LICENSE-2.0
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * * License for the specific language governing permissions and limitations under
+ * * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.state;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ *
+ */
+public interface TokenStateJournal {
+
+ /**
+ * Persist the token state to the journal.
+ *
+ * @param tokenId The unique token identifier
+ * @param issueTime The issue timestamp
+ * @param expiration The expiration time
+ * @param maxLifetime The maximum allowed lifetime
+ */
+ void add(String tokenId, long issueTime, long expiration, long maxLifetime)
+ throws IOException;
+
+ /**
+ * Persist the token state to the journal.
+ *
+ * @param entry The entry to persist
+ */
+ void add(JournalEntry entry) throws IOException;
+
+ /**
+ * Persist the token state to the journal.
+ *
+ * @param entries The entries to persist
+ */
+ void add(List<JournalEntry> entries) throws IOException;
+
+ /**
+ * Get the journaled state for the specified token identifier.
+ *
+ * @param tokenId The unique token identifier.
+ *
+ * @return A JournalEntry with the specified token's journaled state.
+ */
+ JournalEntry get(String tokenId) throws IOException;
+
+ /**
+ * Get all the the journaled tokens' state.
+ *
+ * @return A List of JournalEntry objects.
+ */
+ List<JournalEntry> get() throws IOException;
+
+ /**
+ * Remove the token state for the specified token from the journal
+ *
+ * @param tokenId The unique token identifier
+ */
+ void remove(String tokenId) throws IOException;
+
+ /**
+ * Remove the token state for the specified tokens from the journal
+ *
+ * @param tokenIds A set of unique token identifiers
+ */
+ void remove(Collection<String> tokenIds) throws IOException;
+
+ /**
+ * Remove the token state for the specified journal entry
+ *
+ * @param entry A JournalEntry for the token for which the state should be removed
+ */
+ void remove(JournalEntry entry) throws IOException;
+
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
index cd4d063..6a08635 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
@@ -22,27 +22,59 @@
import org.apache.knox.gateway.services.security.AliasServiceException;
import org.apache.knox.gateway.services.security.token.TokenStateService;
import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.apache.knox.gateway.services.token.impl.state.TokenStateJournalFactory;
import org.easymock.EasyMock;
+import org.junit.Rule;
import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.security.cert.Certificate;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
import static org.easymock.EasyMock.anyObject;
import static org.easymock.EasyMock.anyString;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class AliasBasedTokenStateServiceTest extends DefaultTokenStateServiceTest {
+ @Rule
+ public final TemporaryFolder testFolder = new TemporaryFolder();
+
+ private Path gatewaySecurityDir;
+
+ private Long tokenStatePersistenceInterval = TimeUnit.SECONDS.toMillis(15);
+
+ @Override
+ protected String getGatewaySecurityDir() throws IOException {
+ if (gatewaySecurityDir == null) {
+ gatewaySecurityDir = testFolder.newFolder().toPath();
+ Files.createDirectories(gatewaySecurityDir);
+ }
+ return gatewaySecurityDir.toString();
+ }
+
+ @Override
+ protected long getTokenStatePersistenceInterval() {
+ return (tokenStatePersistenceInterval != null) ? tokenStatePersistenceInterval : super.getTokenStatePersistenceInterval();
+ }
+
/**
* KNOX-2375
*/
@@ -105,8 +137,10 @@
@Test
public void testAddAndRemoveTokenIncludesCache() throws Exception {
+ final int TOKEN_COUNT = 10;
+
final Set<JWTToken> testTokens = new HashSet<>();
- for (int i = 0; i < 10 ; i++) {
+ for (int i = 0; i < TOKEN_COUNT ; i++) {
testTokens.add(createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)));
}
@@ -161,6 +195,7 @@
// Sleep to allow the eviction evaluation to be performed
Thread.sleep(evictionInterval + (evictionInterval / 4));
+
} finally {
tss.stop();
}
@@ -270,6 +305,8 @@
@Test
public void testGetMaxLifetimeUsesCache() throws Exception {
AliasService aliasService = EasyMock.createMock(AliasService.class);
+ aliasService.addAliasesForCluster(anyString(), anyObject());
+ EasyMock.expectLastCall().once(); // Expecting this during shutdown
EasyMock.replay(aliasService);
@@ -332,6 +369,8 @@
EasyMock.expectLastCall().andVoid().atLeastOnce();
aliasService.removeAliasForCluster(anyString(), anyObject());
EasyMock.expectLastCall().andVoid().atLeastOnce();
+ aliasService.addAliasesForCluster(anyString(), anyObject());
+ EasyMock.expectLastCall().andVoid().once(); // Expecting this during shutdown
EasyMock.replay(aliasService);
@@ -372,11 +411,14 @@
tss.updateExpiration(tokenId, updatedExpiration);
}
- //invoking with true/false validation flags as it should not affect if values are coming from the cache
+ // Invoking with true/false validation flags as it should not affect if values are coming from the cache
int count = 0;
for (String tokenId : tokenExpirations.keySet()) {
- assertEquals("Expected the cached expiration to have been updated.", updatedExpiration, tss.getTokenExpiration(tokenId, count++ % 2 == 0));
+ assertEquals("Expected the cached expiration to have been updated.",
+ updatedExpiration,
+ tss.getTokenExpiration(tokenId, count++ % 2 == 0));
}
+
} finally {
tss.stop();
}
@@ -385,8 +427,145 @@
EasyMock.verify(aliasService);
}
+ @Test
+ public void testTokenStateJournaling() throws Exception {
+ AliasService aliasService = EasyMock.createMock(AliasService.class);
+ aliasService.getAliasesForCluster(anyString());
+ EasyMock.expectLastCall().andReturn(Collections.emptyList()).anyTimes();
+ aliasService.addAliasesForCluster(anyString(), anyObject());
+ EasyMock.expectLastCall().once();
+ EasyMock.replay(aliasService);
+
+ tokenStatePersistenceInterval = 1L; // Override the persistence interval for this test
+
+ AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+ tss.setAliasService(aliasService);
+ initTokenStateService(tss);
+
+ Field maxTokenLifetimesField = tss.getClass().getSuperclass().getDeclaredField("maxTokenLifetimes");
+ maxTokenLifetimesField.setAccessible(true);
+ Map<String, Long> maxTokenLifetimes = (Map<String, Long>) maxTokenLifetimesField.get(tss);
+
+ Path journalDir = Paths.get(getGatewaySecurityDir(), "token-state");
+
+ final long evictionInterval = TimeUnit.SECONDS.toMillis(3);
+ final long maxTokenLifetime = evictionInterval * 3;
+
+ final List<String> tokenIds = new ArrayList<>();
+ final Set<JWTToken> testTokens = new HashSet<>();
+ for (int i = 0; i < 10 ; i++) {
+ JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
+ testTokens.add(token);
+ tokenIds.add(token.getClaim(JWTToken.KNOX_ID_CLAIM));
+ }
+
+ try {
+ tss.start();
+
+ // Add the expired tokens
+ for (JWTToken token : testTokens) {
+ tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+ System.currentTimeMillis(),
+ token.getExpiresDate().getTime(),
+ maxTokenLifetime);
+ }
+
+ assertEquals("Expected the tokens lifetimes to have been added in the base class cache.",
+ 10,
+ maxTokenLifetimes.size());
+
+ // Check for the expected number of files corresponding to journal entries
+ List<Path> listing = Files.list(journalDir).collect(Collectors.toList());
+ assertFalse(listing.isEmpty());
+ assertEquals(10, listing.size());
+
+ // Validate the journal entry file names
+ for (Path p : listing) {
+ Path filename = p.getFileName();
+ String filenameString = filename.toString();
+ assertTrue(filenameString.endsWith(".ts"));
+ String tokenId = filenameString.substring(0, filenameString.length() - 3);
+ assertTrue(tokenIds.contains(tokenId));
+ }
+
+ // Sleep to allow the persistence to be performed
+ Thread.sleep(TimeUnit.SECONDS.toMillis(tokenStatePersistenceInterval) * 2);
+
+ } finally {
+ tss.stop();
+ tokenStatePersistenceInterval = null;
+ }
+
+ // Verify that the expected methods were invoked
+ EasyMock.verify(aliasService);
+
+ // Verify that the journal entries were removed when the aliases were created
+ List<Path> listing = Files.list(journalDir).collect(Collectors.toList());
+ assertTrue(listing.isEmpty());
+ }
+
+ @Test
+ public void testLoadTokenStateJournalDuringInit() throws Exception {
+ final int TOKEN_COUNT = 10;
+
+ AliasService aliasService = EasyMock.createMock(AliasService.class);
+ aliasService.getAliasesForCluster(anyString());
+ EasyMock.expectLastCall().andReturn(Collections.emptyList()).anyTimes();
+ EasyMock.replay(aliasService);
+
+ // Create some test tokens
+ final Set<JWTToken> testTokens = new HashSet<>();
+ for (int i = 0; i < TOKEN_COUNT ; i++) {
+ JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
+ testTokens.add(token);
+ }
+
+ // Persist the token state journal entries before initializing the TokenStateService
+ TokenStateJournal journal = TokenStateJournalFactory.create(createMockGatewayConfig(false));
+ for (JWTToken token : testTokens) {
+ journal.add(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+ System.currentTimeMillis(),
+ token.getExpiresDate().getTime(),
+ System.currentTimeMillis() + TimeUnit.HOURS.toMillis(24));
+ }
+
+ AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+ tss.setAliasService(aliasService);
+
+ // Initialize the service, and presumably load the previously-persisted journal entries
+ initTokenStateService(tss);
+
+ Field tokenExpirationsField = tss.getClass().getSuperclass().getDeclaredField("tokenExpirations");
+ tokenExpirationsField.setAccessible(true);
+ Map<String, Long> tokenExpirations = (Map<String, Long>) tokenExpirationsField.get(tss);
+
+ Field maxTokenLifetimesField = tss.getClass().getSuperclass().getDeclaredField("maxTokenLifetimes");
+ maxTokenLifetimesField.setAccessible(true);
+ Map<String, Long> maxTokenLifetimes = (Map<String, Long>) maxTokenLifetimesField.get(tss);
+
+ Field unpersistedStateField = tss.getClass().getDeclaredField("unpersistedState");
+ unpersistedStateField.setAccessible(true);
+ List<AliasBasedTokenStateService.TokenState> unpersistedState =
+ (List<AliasBasedTokenStateService.TokenState>) unpersistedStateField.get(tss);
+
+ assertEquals("Expected the tokens expirations to have been added in the base class cache.",
+ TOKEN_COUNT,
+ tokenExpirations.size());
+
+ assertEquals("Expected the tokens lifetimes to have been added in the base class cache.",
+ TOKEN_COUNT,
+ maxTokenLifetimes.size());
+
+ assertEquals("Expected the unpersisted state to have been added.",
+ (TOKEN_COUNT * 2), // Two TokenState entries per token (expiration, max lifetime)
+ unpersistedState.size());
+
+ // Verify that the expected methods were invoked
+ EasyMock.verify(aliasService);
+ }
+
@Override
- protected TokenStateService createTokenStateService() {
+ protected TokenStateService createTokenStateService() throws Exception {
AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
tss.setAliasService(new TestAliasService());
initTokenStateService(tss);
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
index 27d38bf..190dd70 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
@@ -29,6 +29,7 @@
import org.junit.BeforeClass;
import org.junit.Test;
+import java.io.IOException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
@@ -165,7 +166,7 @@
}
@Test
- public void testNegativeTokenEviction() throws InterruptedException, UnknownTokenException {
+ public void testNegativeTokenEviction() throws Exception {
final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
final TokenStateService tss = createTokenStateService();
@@ -189,7 +190,7 @@
}
@Test
- public void testTokenEviction() throws InterruptedException, ServiceLifecycleException, UnknownTokenException {
+ public void testTokenEviction() throws Exception {
final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
final TokenStateService tss = createTokenStateService();
@@ -218,7 +219,7 @@
}
@Test
- public void testTokenPermissiveness() throws UnknownTokenException {
+ public void testTokenPermissiveness() throws Exception {
final long expiry = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(300);
final JWT token = getJWTToken(expiry);
TokenStateService tss = new DefaultTokenStateService();
@@ -232,7 +233,7 @@
}
@Test(expected = UnknownTokenException.class)
- public void testTokenPermissivenessNoExpiry() throws UnknownTokenException {
+ public void testTokenPermissivenessNoExpiry() throws Exception {
final JWT token = getJWTToken(-1L);
TokenStateService tss = new DefaultTokenStateService();
try {
@@ -258,17 +259,25 @@
return token;
}
- protected static GatewayConfig createMockGatewayConfig(boolean tokenPermissiveness) {
+ protected GatewayConfig createMockGatewayConfig(boolean tokenPermissiveness) throws Exception {
+ return createMockGatewayConfig(tokenPermissiveness, getGatewaySecurityDir(), getTokenStatePersistenceInterval());
+ }
+
+ protected GatewayConfig createMockGatewayConfig(boolean tokenPermissiveness,
+ final String securityDir,
+ long statePersistenceInterval) {
GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
/* configure token eviction time to be 2 secs for test */
EasyMock.expect(config.getKnoxTokenEvictionInterval()).andReturn(2L).anyTimes();
EasyMock.expect(config.getKnoxTokenEvictionGracePeriod()).andReturn(0L).anyTimes();
EasyMock.expect(config.isKnoxTokenPermissiveValidationEnabled()).andReturn(tokenPermissiveness).anyTimes();
+ EasyMock.expect(config.getKnoxTokenStateAliasPersistenceInterval()).andReturn(statePersistenceInterval).anyTimes();
+ EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir).anyTimes();
EasyMock.replay(config);
return config;
}
- protected void initTokenStateService(TokenStateService tss) {
+ protected void initTokenStateService(TokenStateService tss) throws Exception {
try {
tss.init(createMockGatewayConfig(false), Collections.emptyMap());
} catch (ServiceLifecycleException e) {
@@ -276,7 +285,15 @@
}
}
- protected TokenStateService createTokenStateService() {
+ protected long getTokenStatePersistenceInterval() {
+ return TimeUnit.SECONDS.toMillis(15);
+ }
+
+ protected String getGatewaySecurityDir() throws IOException {
+ return null;
+ }
+
+ protected TokenStateService createTokenStateService() throws Exception {
TokenStateService tss = new DefaultTokenStateService();
initTokenStateService(tss);
return tss;
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/AbstractFileTokenStateJournalTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/AbstractFileTokenStateJournalTest.java
new file mode 100644
index 0000000..d0a385c
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/AbstractFileTokenStateJournalTest.java
@@ -0,0 +1,230 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+public abstract class AbstractFileTokenStateJournalTest {
+
+ @Rule
+ public final TemporaryFolder testFolder = new TemporaryFolder();
+
+ abstract TokenStateJournal createTokenStateJournal(GatewayConfig config) throws IOException;
+
+ protected JournalEntry createTestJournalEntry(final String tokenId,
+ long issueTime,
+ long expiration,
+ long maxLifetime) {
+ return new FileTokenStateJournal.FileJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+ }
+
+ protected GatewayConfig getGatewayConfig() throws IOException {
+ final Path dataDir = testFolder.newFolder().toPath();
+ System.out.println("dataDir : " + dataDir.toString());
+ Files.createDirectories(dataDir.resolve("security")); // Make sure the security directory exists
+
+ GatewayConfigImpl config = new GatewayConfigImpl();
+ config.set("gateway.data.dir", dataDir.toString());
+ return config;
+ }
+
+ @Test
+ public void testSingleTokenRoundTrip() throws Exception {
+ GatewayConfig config = getGatewayConfig();
+
+ TokenStateJournal journal = createTokenStateJournal(config);
+
+ final String tokenId = String.valueOf(UUID.randomUUID());
+
+ // Verify that the token state has not yet been journaled
+ assertNull(journal.get(tokenId));
+
+ long issueTime = System.currentTimeMillis();
+ long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+ long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+ journal.add(tokenId, issueTime, expiration, maxLifetime);
+
+ // Get the token state from the journal, and validate its contents
+ JournalEntry entry = journal.get(tokenId);
+ assertNotNull(entry);
+ assertEquals(tokenId, entry.getTokenId());
+ assertEquals(issueTime, Long.parseLong(entry.getIssueTime()));
+ assertEquals(expiration, Long.parseLong(entry.getExpiration()));
+ assertEquals(maxLifetime, Long.parseLong(entry.getMaxLifetime()));
+
+ journal.remove(tokenId);
+
+ // Verify that the token state can no longer be gotten from the journal
+ assertNull(journal.get(tokenId));
+ }
+
+ @Test
+ public void testUpdateTokenState() throws Exception {
+ GatewayConfig config = getGatewayConfig();
+
+ TokenStateJournal journal = createTokenStateJournal(config);
+
+ final String tokenId = String.valueOf(UUID.randomUUID());
+
+ // Verify that the token state has not yet been journaled
+ assertNull(journal.get(tokenId));
+
+ long issueTime = System.currentTimeMillis();
+ long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+ long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+ journal.add(tokenId, issueTime, expiration, maxLifetime);
+
+ // Get the token state from the journal, and validate its contents
+ JournalEntry entry = journal.get(tokenId);
+ assertNotNull(entry);
+ assertEquals(tokenId, entry.getTokenId());
+ assertEquals(issueTime, Long.parseLong(entry.getIssueTime()));
+ assertEquals(expiration, Long.parseLong(entry.getExpiration()));
+ assertEquals(maxLifetime, Long.parseLong(entry.getMaxLifetime()));
+
+ long updatedExpiration = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5);
+ journal.add(tokenId, issueTime, updatedExpiration, maxLifetime);
+
+ // Get and validate the updated token state
+ entry = journal.get(tokenId);
+ assertNotNull(entry);
+ assertEquals(tokenId, entry.getTokenId());
+ assertEquals(issueTime, Long.parseLong(entry.getIssueTime()));
+ assertEquals(updatedExpiration, Long.parseLong(entry.getExpiration()));
+ assertEquals(maxLifetime, Long.parseLong(entry.getMaxLifetime()));
+
+ // Verify that the token state can no longer be gotten from the journal
+ journal.remove(tokenId);
+ assertNull(journal.get(tokenId));
+ }
+
+ @Test
+ public void testSingleJournalEntryRoundTrip() throws Exception {
+ GatewayConfig config = getGatewayConfig();
+
+ TokenStateJournal journal = createTokenStateJournal(config);
+
+ final String tokenId = String.valueOf(UUID.randomUUID());
+
+ // Verify that the token state has not yet been journaled
+ assertNull(journal.get(tokenId));
+
+ long issueTime = System.currentTimeMillis();
+ long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+ long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+ JournalEntry original = createTestJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+ journal.add(original);
+
+ // Get the token state from the journal, and validate its contents
+ JournalEntry entry = journal.get(tokenId);
+ assertNotNull(entry);
+ assertEquals(original.getTokenId(), entry.getTokenId());
+ assertEquals(original.getIssueTime(), entry.getIssueTime());
+ assertEquals(original.getExpiration(), entry.getExpiration());
+ assertEquals(original.getMaxLifetime(), entry.getMaxLifetime());
+
+ journal.remove(entry);
+
+ // Verify that the token state can no longer be gotten from the journal
+ assertNull(journal.get(tokenId));
+ }
+
+ @Test
+ public void testMultipleTokensRoundTrip() throws Exception {
+ GatewayConfig config = getGatewayConfig();
+
+ TokenStateJournal journal = createTokenStateJournal(config);
+
+ final List<String> tokenIds = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ tokenIds.add(String.valueOf(UUID.randomUUID()));
+ }
+
+ Map<String, JournalEntry> journalEntries = new HashMap<>();
+
+ // Verify that the token state has not yet been journaled, and create a JournalEntry for it
+ for (String tokenId : tokenIds) {
+ assertNull(journal.get(tokenId));
+
+ long issueTime = System.currentTimeMillis();
+ long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+ long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+ journalEntries.put(tokenId, createTestJournalEntry(tokenId, issueTime, expiration, maxLifetime));
+ }
+
+ for (JournalEntry entry : journalEntries.values()) {
+ journal.add(entry);
+ }
+
+ for (Map.Entry<String, JournalEntry> journalEntry : journalEntries.entrySet()) {
+ final String tokenId = journalEntry.getKey();
+ // Get the token state from the journal, and validate its contents
+ JournalEntry entry = journal.get(tokenId);
+ assertNotNull(entry);
+
+ JournalEntry original = journalEntry.getValue();
+ assertEquals(original.getTokenId(), entry.getTokenId());
+ assertEquals(original.getIssueTime(), entry.getIssueTime());
+ assertEquals(original.getExpiration(), entry.getExpiration());
+ assertEquals(original.getMaxLifetime(), entry.getMaxLifetime());
+ }
+
+ // Test loading of persisted token state
+ List<JournalEntry> loadedEntries = journal.get();
+ assertNotNull(loadedEntries);
+ assertFalse(loadedEntries.isEmpty());
+ assertEquals(10, loadedEntries.size());
+ for (JournalEntry loaded : loadedEntries) {
+ JournalEntry original = journalEntries.get(loaded.getTokenId());
+ assertNotNull(original);
+ assertEquals(original.getTokenId(), loaded.getTokenId());
+ assertEquals(original.getIssueTime(), loaded.getIssueTime());
+ assertEquals(original.getExpiration(), loaded.getExpiration());
+ assertEquals(original.getMaxLifetime(), loaded.getMaxLifetime());
+ }
+
+ for (String tokenId : tokenIds) {
+ journal.remove(tokenId);
+ // Verify that the token state can no longer be gotten from the journal
+ assertNull(journal.get(tokenId));
+ }
+ }
+
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournalTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournalTest.java
new file mode 100644
index 0000000..da79a4c
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournalTest.java
@@ -0,0 +1,133 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.junit.Test;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+public class FileTokenStateJournalTest {
+
+ @Test
+ public void testParseJournalEntry() {
+ final String tokenId = UUID.randomUUID().toString();
+ final Long issueTime = System.currentTimeMillis();
+ final Long expiration = issueTime + TimeUnit.HOURS.toMillis(1);
+ final Long maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+ doTestParseJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testParseJournalEntry_MissingMaxLifetime() {
+ final String tokenId = UUID.randomUUID().toString();
+ final Long issueTime = System.currentTimeMillis();
+ final Long expiration = issueTime + TimeUnit.HOURS.toMillis(1);
+ final Long maxLifetime = null;
+
+ doTestParseJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+ }
+
+ @Test
+ public void testParseJournalEntry_MissingIssueTime() {
+ final String tokenId = UUID.randomUUID().toString();
+ final Long issueTime = System.currentTimeMillis();
+ final Long expiration = issueTime + TimeUnit.HOURS.toMillis(1);
+ final Long maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+ doTestParseJournalEntry(tokenId, null, expiration, maxLifetime);
+ }
+
+ @Test
+ public void testParseJournalEntry_MissingIssueAndExpirationTimes() {
+ final String tokenId = UUID.randomUUID().toString();
+ final Long maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+ doTestParseJournalEntry(tokenId, null, null, maxLifetime);
+ }
+
+ @Test
+ public void testParseJournalEntry_OnlyMaxLifetime() {
+ final Long maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+ doTestParseJournalEntry(null, null, null, maxLifetime);
+ }
+
+ @Test
+ public void testParseJournalEntry_AllMissing() {
+ doTestParseJournalEntry(null, null, null, " ");
+ }
+
+ private void doTestParseJournalEntry(final String tokenId,
+ final Long issueTime,
+ final Long expiration,
+ final Long maxLifetime) {
+ doTestParseJournalEntry(tokenId,
+ (issueTime != null ? issueTime.toString() : null),
+ (expiration != null ? expiration.toString() : null),
+ (maxLifetime != null ? maxLifetime.toString() : null));
+ }
+
+ private void doTestParseJournalEntry(final String tokenId,
+ final String issueTime,
+ final String expiration,
+ final String maxLifetime) {
+ StringBuilder entryStringBuilder =
+ new StringBuilder(tokenId != null ? tokenId : "").append(',')
+ .append(issueTime != null ? issueTime : "")
+ .append(',')
+ .append(expiration != null ? expiration : "")
+ .append(',')
+ .append(maxLifetime != null ? maxLifetime : "");
+
+ JournalEntry entry = FileTokenStateJournal.FileJournalEntry.parse(entryStringBuilder.toString());
+ assertNotNull(entry);
+ if (tokenId != null && !tokenId.trim().isEmpty()) {
+ assertEquals(tokenId, entry.getTokenId());
+ } else {
+ assertNull(entry.getTokenId());
+ }
+
+ if (issueTime != null && !issueTime.trim().isEmpty()) {
+ assertEquals(issueTime, entry.getIssueTime());
+ } else {
+ assertNull(entry.getIssueTime());
+ }
+
+ if (expiration != null && !expiration.trim().isEmpty()) {
+ assertEquals(expiration, entry.getExpiration());
+ } else {
+ assertNull(entry.getExpiration());
+ }
+
+ if (maxLifetime != null && !maxLifetime.trim().isEmpty()) {
+ assertEquals(maxLifetime, entry.getMaxLifetime());
+ } else {
+ assertNull(entry.getMaxLifetime());
+ }
+ }
+
+
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournalTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournalTest.java
new file mode 100644
index 0000000..938a7e4
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournalTest.java
@@ -0,0 +1,33 @@
+/*
+ *
+ * * Licensed to the Apache Software Foundation (ASF) under one or more
+ * * contributor license agreements. See the NOTICE file distributed with this
+ * * work for additional information regarding copyright ownership. The ASF
+ * * licenses this file to you under the Apache License, Version 2.0 (the
+ * * "License"); you may not use this file except in compliance with the License.
+ * * You may obtain a copy of the License at
+ * *
+ * * http://www.apache.org/licenses/LICENSE-2.0
+ * *
+ * * Unless required by applicable law or agreed to in writing, software
+ * * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * * License for the specific language governing permissions and limitations under
+ * * the License.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+
+import java.io.IOException;
+
+public class MultiFileTokenStateJournalTest extends AbstractFileTokenStateJournalTest {
+
+ @Override
+ TokenStateJournal createTokenStateJournal(GatewayConfig config) throws IOException {
+ return new MultiFileTokenStateJournal(config);
+ }
+
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
index 602cf53..d9a8a16 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
@@ -154,12 +154,15 @@
long getTokenExpiration(String tokenId) throws UnknownTokenException;
/**
- *
- * @param tokenId The token unique identifier.
- * @param validate Flag indicating whether the token needs to be validated.
- *
- * @return The token's expiration time in milliseconds.
- */
+ * Get the expiration for the specified token, optionally validating the token prior to accessing its expiration.
+ * In some cases, the token has already been validated, and skipping an additional unnecessary validation improves
+ * performance.
+ *
+ * @param tokenId The token unique identifier.
+ * @param validate Flag indicating whether the token needs to be validated.
+ *
+ * @return The token's expiration time in milliseconds.
+ */
long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException;
}