Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions cms-auth/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<groupId>com.condation.cms</groupId>
<artifactId>cms-api</artifactId>
</dependency>
<dependency>
<groupId>com.condation.cms</groupId>
<artifactId>cms-core</artifactId>
</dependency>
<dependency>
<groupId>com.condation.cms</groupId>
<artifactId>cms-filesystem</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@
* <http://www.gnu.org/licenses/gpl-3.0.html>.
* #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;
Expand All @@ -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<User> 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<String, Object> 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<String> 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<String, Object> 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<Map<String, Object>>() {
}.getType());
} catch (Exception e) {
log.warn("Konnte Nutzerdaten nicht lesen für {}", username, e);
}
}

return new User(username, passwordHash, groups, data);
}

private List<User> loadUsers(final Realm realm) throws IOException {
Path usersFile = hostBase.resolve("config/" + FILENAME_PATTERN.formatted(realm.name));
List<User> users = new ArrayList<>();
Expand All @@ -90,53 +115,73 @@ private List<User> loadUsers(final Realm realm) throws IOException {
}
}
}

return users;
}

public Optional<User> 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<String, Object> 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<User> 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<String, Object> 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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");

Expand Down