diff --git a/core/src/main/java/org/springframework/security/token/DefaultToken.java b/core/src/main/java/org/springframework/security/token/DefaultToken.java new file mode 100644 index 0000000000..b6ecf96672 --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/DefaultToken.java @@ -0,0 +1,59 @@ +package org.springframework.security.token; + +import java.util.Date; + +import org.springframework.util.Assert; + +/** + * The default implementation of {@link Token}. + * + * @author Ben Alex + * @since 2.0.1 + */ +public class DefaultToken implements Token { + private String key; + private long keyCreationTime; + private String extendedInformation; + + public DefaultToken(String key, long keyCreationTime, String extendedInformation) { + Assert.hasText(key, "Key required"); + Assert.notNull(extendedInformation, "Extended information cannot be null"); + this.key = key; + this.keyCreationTime = keyCreationTime; + this.extendedInformation = extendedInformation; + } + + public String getKey() { + return key; + } + + public long getKeyCreationTime() { + return keyCreationTime; + } + + public String getExtendedInformation() { + return extendedInformation; + } + + public boolean equals(Object obj) { + if (obj != null && obj instanceof DefaultToken) { + DefaultToken rhs = (DefaultToken) obj; + return this.key.equals(rhs.key) && this.keyCreationTime == rhs.keyCreationTime && this.extendedInformation.equals(rhs.extendedInformation); + } + return false; + } + + public int hashCode() { + int code = 979; + code = code * key.hashCode(); + code = code * new Long(keyCreationTime).hashCode(); + code = code * extendedInformation.hashCode(); + return code; + } + + public String toString() { + return "DefaultToken[key=" + new String(key) + "; creation=" + new Date(keyCreationTime) + "; extended=" + extendedInformation + "]"; + } + + +} diff --git a/core/src/main/java/org/springframework/security/token/KeyBasedPersistenceTokenService.java b/core/src/main/java/org/springframework/security/token/KeyBasedPersistenceTokenService.java new file mode 100644 index 0000000000..07bff76589 --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/KeyBasedPersistenceTokenService.java @@ -0,0 +1,170 @@ +package org.springframework.security.token; + +import java.io.UnsupportedEncodingException; +import java.security.SecureRandom; +import java.util.Date; + +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.codec.binary.Hex; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.security.util.Sha512DigestUtils; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Basic implementation of {@link TokenService} that is compatible with clusters and across machine restarts, + * without requiring database persistence. + * + *
+ * Keys are produced in the format: + *
+ * + *+ * Base64(creationTime + ":" + hex(pseudoRandomNumber) + ":" + extendedInformation + ":" + + * Sha512Hex(creationTime + ":" + hex(pseudoRandomNumber) + ":" + extendedInformation + ":" + serverSecret) ) + *
+ * + *
+ * In the above, creationTime, tokenKey and extendedInformation
+ * are equal to that stored in {@link Token}. The Sha512Hex includes the same payload,
+ * plus a serverSecret.
+ *
+ * The serverSecret varies every millisecond. It relies on two static server-side secrets. The first
+ * is a password, and the second is a server integer. Both of these must remain the same for any issued keys
+ * to subsequently be recognised. The applicable serverSecret in any millisecond is computed by
+ * password + ":" + (creationTime % serverInteger). This approach
+ * further obfuscates the actual server secret and renders attempts to compute the server secret more
+ * limited in usefulness (as any false tokens would be forced to have a creationTime equal
+ * to the computed hash). Recall that framework features depending on token services should reject tokens
+ * that are relatively old in any event.
+ *
+ * A further consideration of this class is the requirement for cryptographically strong pseudo-random numbers. + * To this end, the use of {@link SecureRandomFactoryBean} is recommended to inject the property. + *
+ * + *+ * This implementation uses UTF-8 encoding internally for string manipulation. + *
+ * + * @author Ben Alex + * + */ +public class KeyBasedPersistenceTokenService implements TokenService, InitializingBean { + private int pseudoRandomNumberBits = 256; + private String serverSecret; + private Integer serverInteger; + private SecureRandom secureRandom; + + public Token allocateToken(String extendedInformation) { + Assert.notNull(extendedInformation, "Must provided non-null extendedInformation (but it can be empty)"); + long creationTime = new Date().getTime(); + String serverSecret = computeServerSecretApplicableAt(creationTime); + String pseudoRandomNumber = generatePseudoRandomNumber(); + String content = new Long(creationTime).toString() + ":" + pseudoRandomNumber + ":" + extendedInformation; + + // Compute key + String sha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret); + String keyPayload = content + ":" + sha512Hex; + String key = convertToString(Base64.encodeBase64(convertToBytes(keyPayload))); + + return new DefaultToken(key, creationTime, extendedInformation); + } + + public Token verifyToken(String key) { + if (key == null || "".equals(key)) { + return null; + } + String[] tokens = StringUtils.delimitedListToStringArray(convertToString(Base64.decodeBase64(convertToBytes(key))), ":"); + Assert.isTrue(tokens.length >= 4, "Expected 4 or more tokens but found " + tokens.length); + + long creationTime; + try { + creationTime = Long.decode(tokens[0]).longValue(); + } catch (NumberFormatException nfe) { + throw new IllegalArgumentException("Expected number but found " + tokens[0]); + } + + String serverSecret = computeServerSecretApplicableAt(creationTime); + String pseudoRandomNumber = tokens[1]; + + // Permit extendedInfo to itself contain ":" characters + StringBuffer extendedInfo = new StringBuffer(); + for (int i = 2; i < tokens.length-1; i++) { + if (i > 2) { + extendedInfo.append(":"); + } + extendedInfo.append(tokens[i]); + } + + String sha1Hex = tokens[tokens.length-1]; + + // Verification + String content = new Long(creationTime).toString() + ":" + pseudoRandomNumber + ":" + extendedInfo.toString(); + String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret); + Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure"); + + return new DefaultToken(key, creationTime, extendedInfo.toString()); + } + + private byte[] convertToBytes(String input) { + try { + return input.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + private String convertToString(byte[] bytes) { + try { + return new String(bytes, "UTF-8"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @return a pseduo random number (hex encoded) + */ + private String generatePseudoRandomNumber() { + byte[] randomizedBits = new byte[pseudoRandomNumberBits]; + secureRandom.nextBytes(randomizedBits); + return new String(Hex.encodeHex(randomizedBits)); + } + + private String computeServerSecretApplicableAt(long time) { + return serverSecret + ":" + new Long(time % serverInteger.intValue()).intValue(); + } + + /** + * @param serverSecret the new secret, which can contain a ":" if desired (never being sent to the client) + */ + public void setServerSecret(String serverSecret) { + this.serverSecret = serverSecret; + } + + public void setSecureRandom(SecureRandom secureRandom) { + this.secureRandom = secureRandom; + } + + /** + * @param pseudoRandomNumberBits changes the number of bits issued (must be >= 0; defaults to 256) + */ + public void setPseudoRandomNumberBits(int pseudoRandomNumberBits) { + Assert.isTrue(pseudoRandomNumberBits >= 0, "Must have a positive pseudo random number bit size"); + this.pseudoRandomNumberBits = pseudoRandomNumberBits; + } + + public void setServerInteger(Integer serverInteger) { + this.serverInteger = serverInteger; + } + + public void afterPropertiesSet() throws Exception { + Assert.hasText(serverSecret, "Server secret required"); + Assert.notNull(serverInteger, "Server integer required"); + Assert.notNull(secureRandom, "SecureRandom instance required"); + } +} diff --git a/core/src/main/java/org/springframework/security/token/SecureRandomFactoryBean.java b/core/src/main/java/org/springframework/security/token/SecureRandomFactoryBean.java new file mode 100644 index 0000000000..a7bf036832 --- /dev/null +++ b/core/src/main/java/org/springframework/security/token/SecureRandomFactoryBean.java @@ -0,0 +1,69 @@ +package org.springframework.security.token; + +import java.io.InputStream; +import java.security.SecureRandom; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; + +/** + * Creates a {@link SecureRandom} instance. + * + * @author Ben Alex + * @since 2.0.1 + * + */ +public class SecureRandomFactoryBean implements FactoryBean { + + private String algorithm = "SHA1PRNG"; + private Resource seed; + + public Object getObject() throws Exception { + SecureRandom rnd = SecureRandom.getInstance(algorithm); + + if (seed != null) { + // Seed specified, so use it + byte[] seedBytes = FileCopyUtils.copyToByteArray(seed.getInputStream()); + rnd.setSeed(seedBytes); + } else { + // Request the next bytes, thus eagerly incurring the expense of default seeding + rnd.nextBytes(new byte[1]); + } + + return rnd; + } + + public Class getObjectType() { + return SecureRandom.class; + } + + public boolean isSingleton() { + return false; + } + + /** + * Allows the Pseudo Random Number Generator (PRNG) algorithm to be nominated. Defaults to + * SHA1PRNG. + * + * @param algorithm to use (mandatory) + */ + public void setAlgorithm(String algorithm) { + Assert.hasText(algorithm, "Algorithm required"); + this.algorithm = algorithm; + } + + /** + * Allows the user to specify a resource which will act as a seed for the {@link SecureRandom} + * instance. Specifically, the resource will be read into an {@link InputStream} and those + * bytes presented to the {@link SecureRandom#setSeed(byte[])} method. Note that this will + * simply supplement, rather than replace, the existing seed. As such, it is always safe to + * set a seed using this method (it never reduces randomness). + * + * @param seed to use, ornull if no additional seeding is needed
+ */
+ public void setSeed(Resource seed) {
+ this.seed = seed;
+ }
+}
diff --git a/core/src/main/java/org/springframework/security/token/Token.java b/core/src/main/java/org/springframework/security/token/Token.java
new file mode 100644
index 0000000000..1b09c8960e
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/token/Token.java
@@ -0,0 +1,45 @@
+package org.springframework.security.token;
+
+
+/**
+ * A token issued by {@link TokenService}.
+ *
+ * + * It is important that the keys assigned to tokens are sufficiently randomised and secured that + * they can serve as identifying a unique user session. Implementations of {@link TokenService} + * are free to use encryption or encoding strategies of their choice. It is strongly recommended that + * keys are of sufficient length to balance safety against persistence cost. In relation to persistence + * cost, it is strongly recommended that returned keys are small enough for encoding in a cookie. + *
+ * + * @author Ben Alex + * @since 2.0.1 + */ +public interface Token { + + /** + * Obtains the randomised, secure key assigned to this token. Presentation of this token to + * {@link TokenService} will always return aToken that is equal to the original
+ * Token issued for that key.
+ *
+ * @return a key with appropriate randomness and security.
+ */
+ String getKey();
+
+ /**
+ * The time the token key was initially created is available from this method. Note that a given
+ * token must never have this creation time changed. If necessary, a new token can be
+ * requested from the {@link TokenService} to replace the original token.
+ *
+ * @return the time this token key was created, in the same format as specified by {@link Date#getTime()).
+ */
+ long getKeyCreationTime();
+
+ /**
+ * Obtains the extended information associated within the token, which was presented when the token
+ * was first created.
+ *
+ * @return the user-specified extended information, if any
+ */
+ String getExtendedInformation();
+}
diff --git a/core/src/main/java/org/springframework/security/token/TokenService.java b/core/src/main/java/org/springframework/security/token/TokenService.java
new file mode 100644
index 0000000000..f193b5b7b8
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/token/TokenService.java
@@ -0,0 +1,46 @@
+package org.springframework.security.token;
+
+
+/**
+ * Provides a mechanism to allocate and rebuild secure, randomised tokens.
+ *
+ *
+ * Implementations are solely concern with issuing a new {@link Token} on demand. The
+ * issued Token may contain user-specified extended information. The token also
+ * contains a cryptographically strong, byte array-based key. This permits the token to be
+ * used to identify a user session, if desired. The key can subsequently be re-presented
+ * to the TokenService for verification and reconstruction of a Token
+ * equal to the original Token.
+ *
+ * Given the tightly-focused behaviour provided by this interface, it can serve as a building block + * for more sophisticated token-based solutions. For example, authentication systems that depend on + * stateless session keys. These could, for instance, place the username inside the user-specified + * extended information associated with the key). It is important to recognise that we do not intend + * for this interface to be expanded to provide such capabilities directly. + *
+ * + * @author Ben Alex + * @since 2.0.1 + * + */ +public interface TokenService { + /** + * Forces the allocation of a new {@link Token}. + * + * @param the extended information desired in the token (cannot benull, but can be empty)
+ * @return a new token that has not been issued previously, and is guaranteed to be recognised
+ * by this implementation's {@link #verifyToken(String)} at any future time.
+ */
+ Token allocateToken(String extendedInformation);
+
+ /**
+ * Permits verification the <{@link Token#getKey()} was issued by this TokenService and
+ * reconstructs the corresponding Token.
+ *
+ * @param key as obtained from {@link Token#getKey()} and created by this implementation
+ * @return the token, or null if the token was not issued by this TokenService
+ */
+ Token verifyToken(String key);
+}
diff --git a/core/src/test/java/org/springframework/security/token/DefaultTokenTests.java b/core/src/test/java/org/springframework/security/token/DefaultTokenTests.java
new file mode 100644
index 0000000000..a7254e7cab
--- /dev/null
+++ b/core/src/test/java/org/springframework/security/token/DefaultTokenTests.java
@@ -0,0 +1,43 @@
+package org.springframework.security.token;
+
+import java.util.Date;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+
+/**
+ * Tests {@link DefaultToken}.
+ *
+ * @author Ben Alex
+ *
+ */
+public class DefaultTokenTests {
+ @Test
+ public void testEquality() {
+ String key = "key";
+ long created = new Date().getTime();
+ String extendedInformation = "extended";
+
+ DefaultToken t1 = new DefaultToken(key, created, extendedInformation);
+ DefaultToken t2 = new DefaultToken(key, created, extendedInformation);
+ Assert.assertEquals(t1, t2);
+ }
+
+ @Test(expected=IllegalArgumentException.class)
+ public void testRejectsNullExtendedInformation() {
+ String key = "key";
+ long created = new Date().getTime();
+ new DefaultToken(key, created, null);
+ }
+
+ @Test
+ public void testEqualityWithDifferentExtendedInformation3() {
+ String key = "key";
+ long created = new Date().getTime();
+
+ DefaultToken t1 = new DefaultToken(key, created, "length1");
+ DefaultToken t2 = new DefaultToken(key, created, "longerLength2");
+ Assert.assertFalse(t1.equals(t2));
+ }
+}
diff --git a/core/src/test/java/org/springframework/security/token/KeyBasedPersistenceTokenServiceTests.java b/core/src/test/java/org/springframework/security/token/KeyBasedPersistenceTokenServiceTests.java
new file mode 100644
index 0000000000..ab0895f35d
--- /dev/null
+++ b/core/src/test/java/org/springframework/security/token/KeyBasedPersistenceTokenServiceTests.java
@@ -0,0 +1,84 @@
+
+
+package org.springframework.security.token;
+
+import java.security.SecureRandom;
+import java.util.Date;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+
+/**
+ * Tests {@link KeyBasedPersistenceTokenService}.
+ *
+ * @author Ben Alex
+ *
+ */
+public class KeyBasedPersistenceTokenServiceTests {
+
+ private KeyBasedPersistenceTokenService getService() {
+ SecureRandomFactoryBean fb = new SecureRandomFactoryBean();
+ KeyBasedPersistenceTokenService service = new KeyBasedPersistenceTokenService();
+ service.setServerSecret("MY:SECRET$$$#");
+ service.setServerInteger(new Integer(454545));
+ try {
+ SecureRandom rnd = (SecureRandom) fb.getObject();
+ service.setSecureRandom(rnd);
+ service.afterPropertiesSet();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return service;
+ }
+
+ @Test
+ public void testOperationWithSimpleExtendedInformation() {
+ KeyBasedPersistenceTokenService service = getService();
+ Token token = service.allocateToken("Hello world");
+ Token result = service.verifyToken(token.getKey());
+ Assert.assertEquals(token, result);
+ }
+
+
+ @Test
+ public void testOperationWithComplexExtendedInformation() {
+ KeyBasedPersistenceTokenService service = getService();
+ Token token = service.allocateToken("Hello:world:::");
+ Token result = service.verifyToken(token.getKey());
+ Assert.assertEquals(token, result);
+ }
+
+ @Test
+ public void testOperationWithEmptyRandomNumber() {
+ KeyBasedPersistenceTokenService service = getService();
+ service.setPseudoRandomNumberBits(0);
+ Token token = service.allocateToken("Hello:world:::");
+ Token result = service.verifyToken(token.getKey());
+ Assert.assertEquals(token, result);
+ }
+
+ @Test
+ public void testOperationWithNoExtendedInformation() {
+ KeyBasedPersistenceTokenService service = getService();
+ Token token = service.allocateToken("");
+ Token result = service.verifyToken(token.getKey());
+ Assert.assertEquals(token, result);
+ }
+
+ @Test(expected=IllegalArgumentException.class)
+ public void testOperationWithMissingKey() {
+ KeyBasedPersistenceTokenService service = getService();
+ Token token = new DefaultToken("", new Date().getTime(), "");
+ service.verifyToken(token.getKey());
+ }
+
+ @Test(expected=IllegalArgumentException.class)
+ public void testOperationWithTamperedKey() {
+ KeyBasedPersistenceTokenService service = getService();
+ Token goodToken = service.allocateToken("");
+ String fake = goodToken.getKey().toUpperCase();
+ Token token = new DefaultToken(fake, new Date().getTime(), "");
+ service.verifyToken(token.getKey());
+ }
+}
diff --git a/core/src/test/java/org/springframework/security/token/SecureRandomFactoryBeanTests.java b/core/src/test/java/org/springframework/security/token/SecureRandomFactoryBeanTests.java
new file mode 100644
index 0000000000..0d6dbdb3b0
--- /dev/null
+++ b/core/src/test/java/org/springframework/security/token/SecureRandomFactoryBeanTests.java
@@ -0,0 +1,51 @@
+package org.springframework.security.token;
+
+import java.security.SecureRandom;
+
+import org.junit.Test;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+
+import junit.framework.Assert;
+
+/**
+ * Tests {@link SecureRandomFactoryBean}.
+ *
+ * @author Ben Alex
+ *
+ */
+public class SecureRandomFactoryBeanTests {
+ @Test
+ public void testObjectType() {
+ SecureRandomFactoryBean factory = new SecureRandomFactoryBean();
+ Assert.assertEquals(SecureRandom.class, factory.getObjectType());
+ }
+
+ @Test
+ public void testIsSingleton() {
+ SecureRandomFactoryBean factory = new SecureRandomFactoryBean();
+ Assert.assertFalse(factory.isSingleton());
+ }
+
+ @Test
+ public void testCreatesUsingDefaults() throws Exception {
+ SecureRandomFactoryBean factory = new SecureRandomFactoryBean();
+ Object result = factory.getObject();
+ Assert.assertTrue(result instanceof SecureRandom);
+ int rnd = ((SecureRandom)result).nextInt();
+ Assert.assertTrue(rnd != 0);
+ }
+
+ @Test
+ public void testCreatesUsingSeed() throws Exception {
+ SecureRandomFactoryBean factory = new SecureRandomFactoryBean();
+ Resource resource = new ClassPathResource("org/springframework/security/token/SecureRandomFactoryBeanTests.class");
+ Assert.assertNotNull(resource);
+ factory.setSeed(resource);
+ Object result = factory.getObject();
+ Assert.assertTrue(result instanceof SecureRandom);
+ int rnd = ((SecureRandom)result).nextInt();
+ Assert.assertTrue(rnd != 0);
+ }
+
+}
diff --git a/core/src/test/java/org/springframework/security/util/Sha512DigestUtils.java b/core/src/test/java/org/springframework/security/util/Sha512DigestUtils.java
new file mode 100644
index 0000000000..2ba2a89fac
--- /dev/null
+++ b/core/src/test/java/org/springframework/security/util/Sha512DigestUtils.java
@@ -0,0 +1,87 @@
+package org.springframework.security.util;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import org.apache.commons.codec.binary.Hex;
+
+/**
+ * Provides SHA512 digest methods.
+ *
+ * + * Based on Commons Codec, which does not presently provide SHA512 support. + *
+ * + * @author Ben Alex + * @since 2.0.1 + * + */ +public abstract class Sha512DigestUtils { + /** + * Returns a MessageDigest for the givenalgorithm.
+ *
+ * @param algorithm The MessageDigest algorithm name.
+ * @return An MD5 digest instance.
+ * @throws RuntimeException when a {@link java.security.NoSuchAlgorithmException} is caught,
+ */
+ static MessageDigest getDigest(String algorithm) {
+ try {
+ return MessageDigest.getInstance(algorithm);
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+ }
+
+ /**
+ * Returns an SHA digest.
+ *
+ * @return An SHA digest instance.
+ * @throws RuntimeException when a {@link java.security.NoSuchAlgorithmException} is caught,
+ */
+ private static MessageDigest getSha512Digest() {
+ return getDigest("SHA-512");
+ }
+
+ /**
+ * Calculates the SHA digest and returns the value as a
+ * byte[].
+ *
+ * @param data Data to digest
+ * @return SHA digest
+ */
+ public static byte[] sha(byte[] data) {
+ return getSha512Digest().digest(data);
+ }
+
+ /**
+ * Calculates the SHA digest and returns the value as a
+ * byte[].
+ *
+ * @param data Data to digest
+ * @return SHA digest
+ */
+ public static byte[] sha(String data) {
+ return sha(data.getBytes());
+ }
+
+ /**
+ * Calculates the SHA digest and returns the value as a hex string.
+ *
+ * @param data Data to digest
+ * @return SHA digest as a hex string
+ */
+ public static String shaHex(byte[] data) {
+ return new String(Hex.encodeHex(sha(data)));
+ }
+
+ /**
+ * Calculates the SHA digest and returns the value as a hex string.
+ *
+ * @param data Data to digest
+ * @return SHA digest as a hex string
+ */
+ public static String shaHex(String data) {
+ return new String(Hex.encodeHex(sha(data)));
+ }
+
+}