diff --git a/CHANGELOG.md b/CHANGELOG.md index 1681efa32..e346b09e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ see wiki for more information: [wiki](https://github.com/thmarx/cms/wiki) * **FEATURE** Developer Experience [PR-440](https://github.com/CondationCMS/cms-server/pull/440) * **FEATURE** Aliases for content [442](https://github.com/CondationCMS/cms-server/issues/442) * **FEATURE** Add redirect support for aliases [454](https://github.com/CondationCMS/cms-server/issues/454) +* **FEATURE** Switch password has to secure algorithm [472](https://github.com/CondationCMS/cms-server/issues/472) ### Developer experience diff --git a/cms-auth/pom.xml b/cms-auth/pom.xml index 0bda805cf..79a5caf7f 100644 --- a/cms-auth/pom.xml +++ b/cms-auth/pom.xml @@ -14,6 +14,10 @@ com.condation.cms cms-api + + com.condation.cms + cms-core + com.condation.cms cms-filesystem diff --git a/cms-auth/src/main/java/com/condation/cms/auth/services/UserService.java b/cms-auth/src/main/java/com/condation/cms/auth/services/UserService.java index ca9b45f49..65daf256a 100644 --- a/cms-auth/src/main/java/com/condation/cms/auth/services/UserService.java +++ b/cms-auth/src/main/java/com/condation/cms/auth/services/UserService.java @@ -21,18 +21,21 @@ * . * #L% */ - - import com.condation.cms.auth.utils.SecurityUtil; +import com.condation.cms.core.configuration.GSONProvider; import com.google.common.base.Splitter; import com.google.common.collect.Iterables; +import com.google.gson.reflect.TypeToken; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,36 +47,58 @@ @Slf4j @RequiredArgsConstructor public class UserService { + private static final String FILENAME_PATTERN = "%s.realm"; - + private final static Splitter userSplitter = Splitter.on(":").trimResults(); private final static Splitter groupSplitter = Splitter.on(",").trimResults(); - + private final Path hostBase; - - public void addUser (Realm realm, String username, String password, String [] groups) throws IOException { - var users = loadUsers(realm); - users = new ArrayList<>(users.stream().filter(user -> !user.username.equals(username)).toList()); - users.add(new User(username, SecurityUtil.hash(password), groups)); + + public void addUser(Realm realm, String username, String password, String[] groups) throws IOException { + List users = loadUsers(realm); + users = new ArrayList<>(users.stream() + .filter(user -> !user.username().equals(username)) + .toList()); + + byte[] salt = SecurityUtil.generateSalt(); + String saltBase64 = Base64.getEncoder().encodeToString(salt); + String passwordHash = SecurityUtil.hashPBKDF2(password, salt); + + Map data = new HashMap<>(); + data.put("salt", saltBase64); + + users.add(new User(username, passwordHash, groups, data)); saveUsers(realm, users); } - - public void removeUser (Realm realm, String username) throws IOException { + + public void removeUser(Realm realm, String username) throws IOException { var users = loadUsers(realm); users = new ArrayList<>(users.stream().filter(user -> !user.username.equals(username)).toList()); saveUsers(realm, users); } - + private static User fromString(final String userString) { List userParts = userSplitter.splitToList(userString); - var username = userParts.get(0); - var passwordHash = userParts.get(1); - var groups = Iterables.toArray(groupSplitter.split(userParts.get(2)), String.class); + String username = userParts.get(0); + String passwordHash = userParts.get(1); + String[] groups = Iterables.toArray(groupSplitter.split(userParts.get(2)), String.class); + Map data = new HashMap<>(); - return new User(username, passwordHash, groups); + if (userParts.size() >= 4) { + try { + String json = new String(Base64.getDecoder().decode(userParts.get(3)), StandardCharsets.UTF_8); + data = GSONProvider.GSON.fromJson(json, new TypeToken>() { + }.getType()); + } catch (Exception e) { + log.warn("Konnte Nutzerdaten nicht lesen für {}", username, e); + } + } + + return new User(username, passwordHash, groups, data); } - + private List loadUsers(final Realm realm) throws IOException { Path usersFile = hostBase.resolve("config/" + FILENAME_PATTERN.formatted(realm.name)); List users = new ArrayList<>(); @@ -90,53 +115,73 @@ private List loadUsers(final Realm realm) throws IOException { } } } - + return users; } - + public Optional login(final Realm realm, final String username, final String password) { try { - final String hashedPassword = SecurityUtil.hash(password); - - var userOpt = loadUsers(realm).stream().filter(user -> user.username().equals(username)).findFirst(); - if ( - userOpt.isPresent() - && userOpt.get().passwordHash.equals(hashedPassword)) { - return userOpt; + var userOpt = loadUsers(realm).stream() + .filter(user -> user.username().equals(username)) + .findFirst(); + + if (userOpt.isEmpty()) { + return Optional.empty(); } - - return Optional.empty(); + + User user = userOpt.get(); + Map data = user.data(); + + if (data == null || !data.containsKey("salt")) { + log.warn("Benutzer '{}' hat keinen Salt gespeichert", username); + return Optional.empty(); + } + + String base64Salt = String.valueOf(data.get("salt")); + byte[] salt = Base64.getDecoder().decode(base64Salt); + + boolean valid = SecurityUtil.verifyPassword(password, user.passwordHash(), salt); + + return valid ? Optional.of(user) : Optional.empty(); + } catch (Exception ex) { - log.error("", ex); + log.error("Fehler beim Login von Benutzer '{}'", username, ex); + return Optional.empty(); } - return Optional.empty(); } - - + private void saveUsers(Realm realm, List users) throws IOException { Path usersFile = hostBase.resolve("config/" + FILENAME_PATTERN.formatted(realm.name)); Files.deleteIfExists(usersFile); - + StringBuilder userContent = new StringBuilder(); users.forEach(user -> userContent.append(user.line())); - + Files.writeString(usersFile, "# users file", StandardCharsets.UTF_8, StandardOpenOption.CREATE); Files.writeString(usersFile, userContent, StandardCharsets.UTF_8, StandardOpenOption.CREATE); } - - public static record User (String username, String passwordHash, String[] groups) { - - public String line () { - return "%s:%s:%s\r\n".formatted( - username, - passwordHash, - groups!= null ? String.join(",", groups) : "" - ); + + public static record User(String username, String passwordHash, String[] groups, Map data) { + + public String line() { + try { + String json = GSONProvider.GSON.toJson(data != null ? data : Map.of()); + String encodedData = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + return "%s:%s:%s:%s\r\n".formatted( + username, + passwordHash, + groups != null ? String.join(",", groups) : "", + encodedData + ); + } catch (Exception e) { + throw new RuntimeException("Error writing user data", e); + } } } - - public static record Realm (String name) { - public static Realm of (String name) { + + public static record Realm(String name) { + + public static Realm of(String name) { return new Realm(name); } } diff --git a/cms-auth/src/main/java/com/condation/cms/auth/utils/SecurityUtil.java b/cms-auth/src/main/java/com/condation/cms/auth/utils/SecurityUtil.java index 83cabc072..b8f1d0dbf 100644 --- a/cms-auth/src/main/java/com/condation/cms/auth/utils/SecurityUtil.java +++ b/cms-auth/src/main/java/com/condation/cms/auth/utils/SecurityUtil.java @@ -22,22 +22,63 @@ * #L% */ - -import com.google.common.base.Charsets; -import com.google.common.hash.Hashing; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Base64; -/** - * - * @author t.marx - */ public class SecurityUtil { - private static final SecureRandom RANDOM = new SecureRandom(); + private static final int SALT_LENGTH = 16; // in Bytes + private static final int ITERATIONS = 100_000; + private static final int KEY_LENGTH = 256; // in Bits + + private static SecureRandom random = new SecureRandom(); + - public static String hash (final String value) { - return Hashing.sha256() - .hashString(value, Charsets.UTF_8) - .toString(); - } + /** + * create a random salt + * @return random salt as byte array + */ + public static byte[] generateSalt() { + byte[] salt = new byte[SALT_LENGTH]; + random.nextBytes(salt); + return salt; + } + + /** + * creates PBKDF2-Hash (SHA-256) from password and salt. + * @param password + * @param salt + * @return the hash base64 encoded + */ + public static String hashPBKDF2(String password, byte[] salt) { + try { + PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH); + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + byte[] hash = skf.generateSecret(spec).getEncoded(); + return Base64.getEncoder().encodeToString(hash); + } catch (InvalidKeySpecException | java.security.NoSuchAlgorithmException e) { + throw new RuntimeException("Fehler beim Passwort-Hashing", e); + } + } + + /** + * verifies a password + * + * @param inputPassword the password + * @param expectedBase64Hash the expected has + * @param salt the salt + * @return + */ + public static boolean verifyPassword(String inputPassword, String expectedBase64Hash, byte[] salt) { + String inputBase64Hash = hashPBKDF2(inputPassword, salt); + return MessageDigest.isEqual( + inputBase64Hash.getBytes(StandardCharsets.UTF_8), + expectedBase64Hash.getBytes(StandardCharsets.UTF_8) + ); + } } diff --git a/cms-auth/src/test/java/com/condation/cms/auth/services/UserServiceTest.java b/cms-auth/src/test/java/com/condation/cms/auth/services/UserServiceTest.java index 099b68180..e4fc05fb3 100644 --- a/cms-auth/src/test/java/com/condation/cms/auth/services/UserServiceTest.java +++ b/cms-auth/src/test/java/com/condation/cms/auth/services/UserServiceTest.java @@ -23,7 +23,6 @@ */ -import com.condation.cms.auth.services.UserService; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -50,7 +49,7 @@ public static void setup () throws IOException { @Test - public void testSomeMethod() throws IOException { + public void test_login_and_remove() throws IOException { var realm = UserService.Realm.of("users");