This is an automated email from the ASF dual-hosted git repository.
pzampino pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git
The following commit(s) were added to refs/heads/master by this push:
new d1852e3 KNOX-2377 - Address potential loss of token state (#345)
d1852e3 is described below
commit d1852e3af0fb0513a8ecaff00fad3967bcccde55
Author: Phil Zampino <[email protected]>
AuthorDate: Mon Jun 15 15:55:52 2020 -0400
KNOX-2377 - Address potential loss of token state (#345)
---
.../token/impl/AliasBasedTokenStateService.java | 73 ++++++-
.../token/impl/TokenStateServiceMessages.java | 30 +++
.../token/impl/state/FileTokenStateJournal.java | 221 ++++++++++++++++++++
.../impl/state/MultiFileTokenStateJournal.java | 142 +++++++++++++
.../token/impl/state/TokenStateJournalFactory.java | 32 +++
.../gateway/services/token/state/JournalEntry.java | 51 +++++
.../services/token/state/TokenStateJournal.java | 92 +++++++++
.../impl/AliasBasedTokenStateServiceTest.java | 187 ++++++++++++++++-
.../token/impl/DefaultTokenStateServiceTest.java | 31 ++-
.../state/AbstractFileTokenStateJournalTest.java | 230 +++++++++++++++++++++
.../impl/state/FileTokenStateJournalTest.java | 133 ++++++++++++
.../impl/state/MultiFileTokenStateJournalTest.java | 33 +++
.../services/security/token/TokenStateService.java | 15 +-
13 files changed, 1248 insertions(+), 22 deletions(-)
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.ServiceLifecycleException;
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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
private final List<TokenState> unpersistedState = new ArrayList<>();
+ private TokenStateJournal journal;
+
public void setAliasService(AliasService aliasService) {
this.aliasService = aliasService;
}
@@ -59,6 +65,32 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
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 class AliasBasedTokenStateService extends
DefaultTokenStateService {
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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
@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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
@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 @@ public class AliasBasedTokenStateService extends
DefaultTokenStateService {
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 @@ public interface TokenStateServiceMessages {
@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.AliasService;
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 @@ public class AliasBasedTokenStateServiceTest extends
DefaultTokenStateServiceTes
@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 @@ public class AliasBasedTokenStateServiceTest extends
DefaultTokenStateServiceTes
// Sleep to allow the eviction evaluation to be performed
Thread.sleep(evictionInterval + (evictionInterval / 4));
+
} finally {
tss.stop();
}
@@ -270,6 +305,8 @@ public class AliasBasedTokenStateServiceTest extends
DefaultTokenStateServiceTes
@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 @@ public class AliasBasedTokenStateServiceTest extends
DefaultTokenStateServiceTes
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,21 +411,161 @@ public class AliasBasedTokenStateServiceTest extends
DefaultTokenStateServiceTes
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();
+ }
+
+ // Verify that the expected methods were invoked
+ 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.easymock.EasyMock;
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 @@ public class DefaultTokenStateServiceTest {
}
@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 @@ public class DefaultTokenStateServiceTest {
}
@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 @@ public class DefaultTokenStateServiceTest {
}
@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 @@ public class DefaultTokenStateServiceTest {
}
@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 @@ public class DefaultTokenStateServiceTest {
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 @@ public class DefaultTokenStateServiceTest {
}
}
- 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 @@ public interface TokenStateService extends Service {
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;
}