/** * Copyright 2016 StreamSets Inc. * * Licensed under 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 com.streamsets.lib.security.http; import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.collect.ImmutableMap; import com.streamsets.datacollector.util.Configuration; import com.streamsets.pipeline.api.impl.Utils; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; import java.security.SecureRandom; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; public class PasswordHasher { private static final Logger LOG = LoggerFactory.getLogger(PasswordHasher.class); public static final String RANDOM_ALGORITHM = "SHA1PRNG"; public static final String HASH_ALGORITHM_V1_V2 = "PBKDF2WithHmacSHA512"; public static final String HASH_ALGORITHM_V3 = "PBKDF2WithHmacSHA1"; static final Map<String, SecretKeyFactory> SECRET_KEY_FACTORIES; private static final SecureRandom SECURE_RANDOM; // hashes password only public static final String V1 = "v1"; // hashes (user + password), to avoid swap-ability of passwords in storage public static final String V2 = "v2"; // V2 but using SHA1 instead of SHA512 because of Java 7 not supporting the later public static final String V3 = "v3"; static { try { SECURE_RANDOM = SecureRandom.getInstance(RANDOM_ALGORITHM); } catch (Exception ex) { throw new RuntimeException(ex); } Map<String, SecretKeyFactory> map = new HashMap<>(); try { SecretKeyFactory factory = SecretKeyFactory.getInstance(HASH_ALGORITHM_V1_V2); map.put(V1, factory); map.put(V2, factory); } catch (Exception ex) { LOG.warn("Algorithm '{}' not available, v1 and v2 hashes are not supported", HASH_ALGORITHM_V1_V2); } try { SecretKeyFactory factory = SecretKeyFactory.getInstance(HASH_ALGORITHM_V3); map.put(V3, factory); } catch (Exception ex) { LOG.warn("Algorithm '{}' not available, v3 hashes are not supported", HASH_ALGORITHM_V3); } if (map.isEmpty()) { throw new RuntimeException("There is no hash algorithm available"); } SECRET_KEY_FACTORIES = ImmutableMap.copyOf(map); } public static Set<String> getSupportedHashVersions() { return SECRET_KEY_FACTORIES.keySet(); } public static final String CONFIG_PREFIX = "passwordHandler."; public static final String HASH_VERSION_KEY = CONFIG_PREFIX + "hashVersion"; public static final String HASH_VERSION_DEFAULT = V3; public static final String ITERATIONS_KEY = CONFIG_PREFIX + "iterations"; public static final int ITERATIONS_DEFAULT = 100000; public static final String KEY_LENGTH_KEY = CONFIG_PREFIX + "keyLength"; public static final int KEY_LENGTH_DEFAULT = 256; private final String hashVersion; private final int iterations; private final int keyLength; private final Cache<String, String> verifyCache; public PasswordHasher(Configuration configuration) { hashVersion = configuration.get(HASH_VERSION_KEY, HASH_VERSION_DEFAULT); iterations = configuration.get(ITERATIONS_KEY, ITERATIONS_DEFAULT); keyLength = configuration.get(KEY_LENGTH_KEY, KEY_LENGTH_DEFAULT); // expire on access of 20mins it is 40 times over the validation time. verifyCache = CacheBuilder.newBuilder().expireAfterAccess(20, TimeUnit.MINUTES).build(); } public String[] getRandomValueAndHash() { byte[] random = new byte[64]; SECURE_RANDOM.nextBytes(random); String value = Hex.encodeHexString(random); String hash = getPasswordHash(value, value); return new String[]{value, hash}; } @VisibleForTesting Cache<String, String> getVerifyCache() { return verifyCache; } public String getPasswordHash(String user, String password) { int iterations = getIterations(); byte[] salt = getSalt(); return computeHash(hashVersion, iterations, salt, getValueToHash(hashVersion, user, password)); } protected String getValueToHash(String hashVersion, String user, String password) { switch (hashVersion) { case V1: return password; case V2: case V3: return user + "\n" + password; default: throw new IllegalArgumentException(Utils.format("Invalid/unsupported hash version '{}'", hashVersion)); } } protected String computeHash(String version, int iterations, byte[] salt, String valueTohash) { long start = System.currentTimeMillis(); try { // yield CPU when this method is run in a tight loop Thread.yield(); PBEKeySpec spec = new PBEKeySpec(valueTohash.toCharArray(), salt, iterations, getKeyLength()); byte[] hash = SECRET_KEY_FACTORIES.get(version).generateSecret(spec).getEncoded(); return version + ":" + iterations + ":" + Hex.encodeHexString(salt) + ":" + Hex.encodeHexString(hash); } catch (Exception ex) { throw new RuntimeException(ex); } finally { LOG.trace( "Computing password hash '{}' with '{}' iterations took '{}msec'", version, iterations, System.currentTimeMillis() - start ); } } protected String getHashVersion(String hash) { String version = "UNKNOWN"; int idx = hash.indexOf(":"); if (idx > -1) { version = hash.substring(0, idx); } return version; } public boolean verify(String storedPasswordHash, String user, String givenPassword) { boolean ok = false; String version = getHashVersion(storedPasswordHash); String valueToHash = getValueToHash(version, user, givenPassword); String cachedPassword = getVerifyCache().getIfPresent(storedPasswordHash); if (cachedPassword != null) { ok = cachedPassword.equals(valueToHash); } else { try { String[] parts = storedPasswordHash.split(":"); if (parts.length > 0) { switch (version) { case V1: case V2: case V3: { if (parts.length == 4) { int hashIterations = Integer.parseInt(parts[1]); byte[] hashSalt = Hex.decodeHex(parts[2].toCharArray()); // we don't need the stored hash (parts[3]) as we compare the fully stored thing String recomputedHash = computeHash(version, hashIterations, hashSalt, valueToHash); ok = storedPasswordHash.equals(recomputedHash); if (ok) { getVerifyCache().put(storedPasswordHash, valueToHash); } } } break; default: throw new IllegalArgumentException(Utils.format("Invalid/unsupported hash version '{}'", version)); } } } catch (Exception ex) { throw new RuntimeException(ex); } } return ok; } public String getCurrentVersion() { return hashVersion; } protected int getIterations() { return iterations; } protected int getKeyLength() { return keyLength; } protected int getSaltLength() { return getKeyLength() / 8; } protected byte[] getSalt() { byte[] salt = new byte[getSaltLength()]; SECURE_RANDOM.nextBytes(salt); return salt; } }