SEC-811: Provide a mechanism to allocate and rebuild cryptographically strong, randomised tokens.

This commit is contained in:
Ben Alex 2008-05-02 10:38:56 +00:00
parent a599ef5398
commit 7a2e1e13d3
9 changed files with 654 additions and 0 deletions

View File

@ -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 + "]";
}
}

View File

@ -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.
*
* <p>
* Keys are produced in the format:
* </p>
*
* <p>
* Base64(creationTime + ":" + hex(pseudoRandomNumber) + ":" + extendedInformation + ":" +
* Sha512Hex(creationTime + ":" + hex(pseudoRandomNumber) + ":" + extendedInformation + ":" + serverSecret) )
* </p>
*
* <p>
* In the above, <code>creationTime</code>, <code>tokenKey</code> and <code>extendedInformation</code>
* are equal to that stored in {@link Token}. The <code>Sha512Hex</code> includes the same payload,
* plus a <code>serverSecret</code>.
* </p>
*
* <p>
* The <code>serverSecret</code> 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 <code>serverSecret</code> in any millisecond is computed by
* <code>password</code> + ":" + (<code>creationTime</code> % <code>serverInteger</code>). 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 <code>creationTime</code> equal
* to the computed hash). Recall that framework features depending on token services should reject tokens
* that are relatively old in any event.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* This implementation uses UTF-8 encoding internally for string manipulation.
* </p>
*
* @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");
}
}

View File

@ -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, or <code>null</code> if no additional seeding is needed
*/
public void setSeed(Resource seed) {
this.seed = seed;
}
}

View File

@ -0,0 +1,45 @@
package org.springframework.security.token;
/**
* A token issued by {@link TokenService}.
*
* <p>
* 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.
* </p>
*
* @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 a <code>Token</code> that is equal to the original
* <code>Token</code> 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();
}

View File

@ -0,0 +1,46 @@
package org.springframework.security.token;
/**
* Provides a mechanism to allocate and rebuild secure, randomised tokens.
*
* <p>
* Implementations are solely concern with issuing a new {@link Token} on demand. The
* issued <code>Token</code> 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 <code>TokenService</code> for verification and reconstruction of a <code>Token</code>
* equal to the original <code>Token</code>.
* </p>
*
* <p>
* 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.
* </p>
*
* @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 be <code>null</code>, 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 <code>TokenService</code> and
* reconstructs the corresponding <code>Token</code>.
*
* @param key as obtained from {@link Token#getKey()} and created by this implementation
* @return the token, or <code>null</code> if the token was not issued by this <code>TokenService</code>
*/
Token verifyToken(String key);
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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.
*
* <p>
* Based on Commons Codec, which does not presently provide SHA512 support.
* </p>
*
* @author Ben Alex
* @since 2.0.1
*
*/
public abstract class Sha512DigestUtils {
/**
* Returns a MessageDigest for the given <code>algorithm</code>.
*
* @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
* <code>byte[]</code>.
*
* @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
* <code>byte[]</code>.
*
* @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)));
}
}