Переглянути джерело

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

Ben Alex 17 роки тому
батько
коміт
7a2e1e13d3

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

+ 170 - 0
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.
+ * 
+ * <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");
+	}
+}

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

+ 45 - 0
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}.
+ * 
+ * <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();
+}

+ 46 - 0
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.
+ * 
+ * <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);
+}

+ 43 - 0
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));
+	}
+}

+ 84 - 0
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());
+	}
+}

+ 51 - 0
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);
+	}
+	
+}

+ 87 - 0
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.
+ * 
+ * <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)));
+    }
+
+}