Password.java
/*
* Copyright (c) 2007-2025 MetaSolutions AB
*
* Licensed 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.entrystore.repository.security;
import com.google.common.collect.Sets;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.commons.codec.binary.Base64;
import org.entrystore.config.Config;
import org.entrystore.repository.config.Settings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Date;
import java.util.Set;
import java.util.function.IntPredicate;
import java.util.regex.Pattern;
/**
* Helper methods for handling hashed and salted passwords, using PBKDF2.
* <p>
* Inspired by Martin Konicek's code on StackOverflow.
*
* @author Hannes Ebner
*/
public class Password {
private static final Logger log = LoggerFactory.getLogger(Password.class);
// Higher number of iterations causes more work for both the attacker and
// our system when checking passwords. Optimally this should be dynamically
// chosen depending on how many iterations the local machine manages in a
// specific amount of time (e.g. < 10 ms); the amount of iterations should
// then be stored together with hash and password.
private static final int iterations = 10 * 1024;
/*
* SecureRandom, which is based internally on the 160-bit SHA-1 hash
* function, can only generate 2^160 possible sequences of any length. So
* there is no point in using more than 160 bits of salt (20 bytes) with
* this generator.
*/
// We use a salt of 16 byte length
private static final int saltLen = 16;
private static final int desiredKeyLen = 192;
public static final int PASSWORD_MAX_LENGTH = 2048;
private static SecureRandom random;
private static SecretKeyFactory secretKeyFactory;
@Getter
@Setter
private static Rules rules;
@Getter
private static final Rules defaultRules = new Rules(true, true, false, true, 10, null);
@AllArgsConstructor
@Getter
@NoArgsConstructor
public static class Rules {
/**
* Upper case character required
*/
boolean uppercase;
/**
* Lower case character required
*/
boolean lowercase;
/**
* Symbol required
*/
boolean symbol;
/**
* Number character required
*/
boolean number;
/**
* Minimum password length
*/
int minLength;
/**
* A set of regular expressions to match against
*/
Set<String> custom;
}
static {
try {
random = SecureRandom.getInstance("SHA1PRNG");
secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
long before = new Date().getTime();
random.setSeed(random.generateSeed(saltLen));
log.info("Seeding of SecureRandom took {} ms", new Date().getTime() - before);
} catch (NoSuchAlgorithmException e) {
log.error(e.getMessage());
}
}
/**
* Computes a salted PBKDF2. Empty passwords are not supported.
*/
public static String getSaltedHash(String password) {
checkMinimumRequirements(password);
byte[] salt = new byte[saltLen];
random.nextBytes(salt);
// return the salt along with the salted and hashed password
return Base64.encodeBase64String(salt) + "$" + hash(password, salt);
}
/**
* Checks whether given plaintext password corresponds to a stored salted
* hash of the password.
*/
public static boolean check(String password, String stored) {
checkMinimumRequirements(password);
if (stored == null) {
throw new IllegalArgumentException("Stored password must not be null");
}
String[] saltAndPass = stored.split("\\$");
if (saltAndPass.length != 2) {
return false;
}
String hashOfInput = hash(password, Base64.decodeBase64(saltAndPass[0]));
if (hashOfInput != null) {
return hashOfInput.equals(saltAndPass[1]);
}
return false;
}
private static String hash(String password, byte[] salt) {
checkMinimumRequirements(password);
try {
long before = new Date().getTime();
SecretKey key = secretKeyFactory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, iterations, desiredKeyLen));
log.info("Password hashing took {} ms", new Date().getTime() - before);
return Base64.encodeBase64String(key.getEncoded());
} catch (GeneralSecurityException gse) {
log.error(gse.getMessage());
}
return null;
}
public static String sha256(String s) {
MessageDigest digester;
try {
digester = MessageDigest.getInstance("SHA-256");
digester.update(s.getBytes(StandardCharsets.UTF_8));
byte[] key = digester.digest();
SecretKeySpec spec = new SecretKeySpec(key, "AES");
return Base64.encodeBase64String(spec.getEncoded());
} catch (NoSuchAlgorithmException nsae) {
log.error(nsae.getMessage());
}
return null;
}
private static void checkMinimumRequirements(String password) {
if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("Empty passwords are not supported");
}
if (password.length() > PASSWORD_MAX_LENGTH) {
throw new IllegalArgumentException("The length of the password must not exceed " + PASSWORD_MAX_LENGTH + " characters");
}
}
public static boolean conformsToRules(String password) {
try {
checkMinimumRequirements(password);
} catch (IllegalArgumentException iae) {
return false;
}
if (rules == null) {
rules = defaultRules;
}
if (password.length() < rules.getMinLength()) {
return false;
}
if (rules.isUppercase() && !containsUpperCase(password)) {
return false;
}
if (rules.isLowercase() && !containsLowerCase(password)) {
return false;
}
if (rules.isNumber() && !containsNumber(password)) {
return false;
}
if (rules.isSymbol() && !containsSymbol(password)) {
return false;
}
if (rules.getCustom() != null) {
for (String expression : rules.getCustom()) {
if (!expression.isEmpty() && !Pattern.compile(expression).matcher(password).find()) {
return false;
}
}
}
return true;
}
private static boolean containsLowerCase(String value) {
return contains(value, i -> Character.isLetter(i) && Character.isLowerCase(i));
}
private static boolean containsUpperCase(String value) {
return contains(value, i -> Character.isLetter(i) && Character.isUpperCase(i));
}
private static boolean containsNumber(String value) {
return contains(value, Character::isDigit);
}
private static boolean containsSymbol(String value) {
return Pattern.compile("[^a-zA-Z\\d]").matcher(value).find();
}
private static boolean contains(String value, IntPredicate predicate) {
return value.chars().anyMatch(predicate);
}
public static void loadRules(Config config) {
rules = new Rules();
rules.lowercase = config.getBoolean(Settings.AUTH_PASSWORD_RULE_LOWERCASE, defaultRules.isLowercase());
rules.uppercase = config.getBoolean(Settings.AUTH_PASSWORD_RULE_UPPERCASE, defaultRules.isUppercase());
rules.number = config.getBoolean(Settings.AUTH_PASSWORD_RULE_NUMBER, defaultRules.isNumber());
rules.symbol = config.getBoolean(Settings.AUTH_PASSWORD_RULE_SYMBOL, defaultRules.isSymbol());
rules.minLength = config.getInt(Settings.AUTH_PASSWORD_RULE_MINLENGTH, defaultRules.getMinLength());
rules.custom = Sets.newHashSet(config.getStringList(Settings.AUTH_PASSWORD_RULE_CUSTOM));
}
}