add SSHA+Argon2 hashed password to accounts profile and validate profile activation (#203)
Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/203 Reviewed-by: Marc Sandlus <hsh-marcsandlus@noreply.dev.hostsharing.net>
This commit is contained in:
@@ -4,6 +4,7 @@ export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted
|
|||||||
export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin
|
export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin
|
||||||
export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net
|
export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net
|
||||||
export HSADMINNG_OFFICE_DATA_SQL_FILE
|
export HSADMINNG_OFFICE_DATA_SQL_FILE
|
||||||
|
export HSADMINNG_ACCOUNT_PASSWORD_HASH_ALGORITHM='{SSHA}'
|
||||||
export HSADMINNG_JWT_TOKEN_URL=http://localhost:8080/fake-jwt/token
|
export HSADMINNG_JWT_TOKEN_URL=http://localhost:8080/fake-jwt/token
|
||||||
export HSADMINNG_JWT_CLIENT_ID=hsscript.ng
|
export HSADMINNG_JWT_CLIENT_ID=hsscript.ng
|
||||||
export HSADMINNG_JWT_CLIENT_SECRET=
|
export HSADMINNG_JWT_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ unset HSADMINNG_SUPERUSER
|
|||||||
unset HSADMINNG_MIGRATION_DATA_PATH
|
unset HSADMINNG_MIGRATION_DATA_PATH
|
||||||
unset HSADMINNG_OFFICE_DATA_SQL_FILE
|
unset HSADMINNG_OFFICE_DATA_SQL_FILE
|
||||||
|
|
||||||
|
unset HSADMINNG_ACCOUNT_PASSWORD_HASH_ALGORITHM
|
||||||
|
|
||||||
unset HSADMINNG_JWT_ISSUER
|
unset HSADMINNG_JWT_ISSUER
|
||||||
unset HSADMINNG_JWT_JWKS_URL
|
unset HSADMINNG_JWT_JWKS_URL
|
||||||
unset HSADMINNG_JWT_TOKEN_URL
|
unset HSADMINNG_JWT_TOKEN_URL
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ dependencies {
|
|||||||
implementation("org.modelmapper:modelmapper:3.2.4")
|
implementation("org.modelmapper:modelmapper:3.2.4")
|
||||||
implementation("org.iban4j:iban4j:3.2.11-RELEASE")
|
implementation("org.iban4j:iban4j:3.2.11-RELEASE")
|
||||||
implementation("org.reflections:reflections:0.10.2")
|
implementation("org.reflections:reflections:0.10.2")
|
||||||
|
implementation("de.mkammerer:argon2-jvm:2.11")
|
||||||
|
|
||||||
compileOnly("org.projectlombok:lombok")
|
compileOnly("org.projectlombok:lombok")
|
||||||
testCompileOnly("org.projectlombok:lombok")
|
testCompileOnly("org.projectlombok:lombok")
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
{ "moduleLicense": "GNU Library General Public License v2.1 or later" },
|
{ "moduleLicense": "GNU Library General Public License v2.1 or later" },
|
||||||
{ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception" },
|
{ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception" },
|
||||||
{ "moduleLicense": "GNU LESSER GENERAL PUBLIC LICENSE, Version 2.1" },
|
{ "moduleLicense": "GNU LESSER GENERAL PUBLIC LICENSE, Version 2.1" },
|
||||||
|
{ "moduleLicense": "GNU LESSER GENERAL PUBLIC LICENSE, Version 3" },
|
||||||
|
|
||||||
{ "moduleLicense": "GPL2 w/ CPE" },
|
{ "moduleLicense": "GPL2 w/ CPE" },
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package net.hostsharing.hsadminng.errors;
|
||||||
|
|
||||||
|
public class ForbiddenException extends RuntimeException {
|
||||||
|
|
||||||
|
public ForbiddenException(final String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,12 @@ public class RestResponseEntityExceptionHandler
|
|||||||
return errorResponse(request, HttpStatus.BAD_REQUEST, localizedMessage(exc));
|
return errorResponse(request, HttpStatus.BAD_REQUEST, localizedMessage(exc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ForbiddenException.class)
|
||||||
|
protected ResponseEntity<CustomErrorResponse> handleForbiddenException(
|
||||||
|
final ForbiddenException exc, final WebRequest request) {
|
||||||
|
return errorResponse(request, HttpStatus.FORBIDDEN, localizedMessage(exc));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class })
|
@ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class })
|
||||||
protected ResponseEntity<CustomErrorResponse> handleJpaObjectRetrievalFailureException(
|
protected ResponseEntity<CustomErrorResponse> handleJpaObjectRetrievalFailureException(
|
||||||
final RuntimeException exc, final WebRequest request) {
|
final RuntimeException exc, final WebRequest request) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class Base64Utils {
|
||||||
|
|
||||||
|
public static boolean isBase64(String str) {
|
||||||
|
if (str == null || str.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64 encoding requires at least 2 characters to represent any meaningful data
|
||||||
|
if (str.length() < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str.contains("=")) {
|
||||||
|
return isValidPaddedBase64(str);
|
||||||
|
} else {
|
||||||
|
return isValidUnpaddedBase64(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidPaddedBase64(final String str) {
|
||||||
|
// if it has padding, the length must be divisible by 4
|
||||||
|
if (str.length() % 4 != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// check padded Base64 format - padding can only be at the end
|
||||||
|
if (!str.matches("^[A-Za-z0-9+/]+={1,2}$")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ensure padding is only at the end
|
||||||
|
int paddingStart = str.indexOf('=');
|
||||||
|
return paddingStart >= str.length() - 2 && str.substring(paddingStart).matches("^=+$");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidUnpaddedBase64(final String str) {
|
||||||
|
// check unpadded Base64 (like Argon2 uses)
|
||||||
|
// unpadded length should make sense - when padded it should be divisible by 4
|
||||||
|
int paddedLength = ((str.length() + 3) / 4) * 4;
|
||||||
|
if (paddedLength - str.length() > 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return str.matches("^[A-Za-z0-9+/]+$");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package net.hostsharing.hsadminng.hash;
|
|||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.PriorityQueue;
|
import java.util.PriorityQueue;
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
import java.util.function.BiFunction;
|
import java.util.function.BiFunction;
|
||||||
import java.util.random.RandomGenerator;
|
import java.util.random.RandomGenerator;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import lombok.val;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Usage-example to generate hash:
|
* Usage-example to generate hash:
|
||||||
@@ -29,6 +31,15 @@ public final class HashGenerator {
|
|||||||
"0123456789/.";
|
"0123456789/.";
|
||||||
private static boolean couldBeHashEnabled; // TODO.legacy: remove after legacy data is migrated
|
private static boolean couldBeHashEnabled; // TODO.legacy: remove after legacy data is migrated
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the hash algorithm from an environment variable.
|
||||||
|
*/
|
||||||
|
public static HashGenerator fromEnv(final String envVarName, final String defaultValue) {
|
||||||
|
val algorithm = Algorithm.byPrefix(
|
||||||
|
Optional.ofNullable(System.getenv(envVarName)).orElse(defaultValue));
|
||||||
|
return using(algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
public enum Algorithm {
|
public enum Algorithm {
|
||||||
LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"),
|
LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"),
|
||||||
LINUX_YESCRYPT(LinuxEtcShadowHashGenerator::hash, "y", "j9T$") {
|
LINUX_YESCRYPT(LinuxEtcShadowHashGenerator::hash, "y", "j9T$") {
|
||||||
@@ -38,7 +49,14 @@ public final class HashGenerator {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
MYSQL_NATIVE(MySQLNativePasswordHashGenerator::hash, "*"),
|
MYSQL_NATIVE(MySQLNativePasswordHashGenerator::hash, "*"),
|
||||||
SCRAM_SHA256(PostgreSQLScramSHA256::hash, "SCRAM-SHA-256");
|
SCRAM_SHA256(PostgreSQLScramSHA256::hash, "SCRAM-SHA-256"),
|
||||||
|
LDAP_SSHA(LdapSshaHash::hash, "{SSHA}"),
|
||||||
|
LDAP_ARGON2(LdapArgon2Hash::hash, "{ARGON2}", "$argon2id$v=19$m=1024,t=2,p=2$") {
|
||||||
|
@Override
|
||||||
|
String enrichedSalt(final String salt) {
|
||||||
|
return prefix + salt;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
final BiFunction<HashGenerator, String, String> implementation;
|
final BiFunction<HashGenerator, String, String> implementation;
|
||||||
final String prefix;
|
final String prefix;
|
||||||
@@ -85,7 +103,7 @@ public final class HashGenerator {
|
|||||||
|
|
||||||
public String hash(final String plaintextPassword) {
|
public String hash(final String plaintextPassword) {
|
||||||
if (plaintextPassword == null) {
|
if (plaintextPassword == null) {
|
||||||
throw new IllegalStateException("no password given");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final var hash = algorithm.implementation.apply(this, plaintextPassword);
|
final var hash = algorithm.implementation.apply(this, plaintextPassword);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import de.mkammerer.argon2.Argon2Factory;
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
import lombok.val;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hash.Base64Utils.isBase64;
|
||||||
|
|
||||||
|
// WARNING: explicit salt and external random salt are silently ignored, a salt gets generated implicitly
|
||||||
|
public class LdapArgon2Hash {
|
||||||
|
|
||||||
|
// align with LDAP config
|
||||||
|
public static final int DEFAULT_ITERATIONS = 3; // t
|
||||||
|
public static final int DEFAULT_MEMORY_KIB = 65536; // m (64 MiB)
|
||||||
|
public static final int DEFAULT_PARALLELISM = 1; // p
|
||||||
|
public static final int DEFAULT_SALT_LEN = 16; // Bytes
|
||||||
|
public static final int DEFAULT_HASH_LEN = 32; // Bytes
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
public static String hash(final HashGenerator generator, final String password) {
|
||||||
|
if (isArgon2Hash(password)) {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
return hashForLdap(password, DEFAULT_ITERATIONS, DEFAULT_MEMORY_KIB,
|
||||||
|
DEFAULT_PARALLELISM, DEFAULT_SALT_LEN, DEFAULT_HASH_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void verifyHash(final String hash, final String password) {
|
||||||
|
// optionally remove prefix to get a pure PHC-String
|
||||||
|
final var phc = hash.startsWith("{ARGON2}") ? hash.substring("{ARGON2}".length()) : hash;
|
||||||
|
final var argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id, DEFAULT_SALT_LEN, DEFAULT_HASH_LEN);
|
||||||
|
if (!argon2.verify(phc, password.toCharArray())) {
|
||||||
|
throw new IllegalArgumentException("invalid password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isArgon2Hash(final String password) {
|
||||||
|
val hash = password.substring(password.lastIndexOf('$') + 1);
|
||||||
|
return password.startsWith("{ARGON2}") && password.contains("$argon2id$")
|
||||||
|
&& hash.length() > 10 && isBase64(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String hashForLdap(final String password,
|
||||||
|
final int iterations,
|
||||||
|
final int memoryKiB,
|
||||||
|
final int parallelism,
|
||||||
|
final int saltLen,
|
||||||
|
final int hashLen) {
|
||||||
|
|
||||||
|
final var argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id, saltLen, hashLen);
|
||||||
|
// generates a PHC-String: $argon2id$v=19$m=..,t=..,p=..$<salt>$<hash>
|
||||||
|
final var phc = argon2.hash(iterations, memoryKiB, parallelism, password.toCharArray());
|
||||||
|
// but LDAP expects {ARGON2}<PHC-String>
|
||||||
|
return "{ARGON2}" + phc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValid(final String hash) {
|
||||||
|
if (!hash.startsWith("{ARGON2}")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final String argon2Part = hash.substring("{ARGON2}".length());
|
||||||
|
return argon2Part.contains("$argon2id$") &&
|
||||||
|
argon2Part.matches("\\$argon2id\\$v=19\\$m=\\d+,t=\\d+,p=\\d+\\$[A-Za-z0-9+/]{22}\\$[A-Za-z0-9+/]{43}");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
import lombok.val;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static net.hostsharing.hsadminng.hash.Base64Utils.isBase64;
|
||||||
|
|
||||||
|
public class LdapSshaHash {
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
public static String hash(final HashGenerator generator, final String password) {
|
||||||
|
if (isValid(password)) {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHA is a salted SHA1-1 hash
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1");
|
||||||
|
val salt = Optional.ofNullable(generator.getSalt()).orElse("").getBytes(UTF_8);
|
||||||
|
digest.update(password.getBytes(UTF_8));
|
||||||
|
digest.update(salt);
|
||||||
|
val hashBytes = digest.digest();
|
||||||
|
|
||||||
|
// Concatenate hash + salt for SSHA format
|
||||||
|
val hashWithSalt = new byte[hashBytes.length + salt.length];
|
||||||
|
System.arraycopy(hashBytes, 0, hashWithSalt, 0, hashBytes.length);
|
||||||
|
System.arraycopy(salt, 0, hashWithSalt, hashBytes.length, salt.length);
|
||||||
|
|
||||||
|
// Encode to Base64 and add SSHA prefix
|
||||||
|
val base64Hash = Base64.getEncoder().encodeToString(hashWithSalt);
|
||||||
|
return "{SSHA}" + base64Hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
public static void verifyHash(final String hash, final String password) {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1");
|
||||||
|
val cleanHash = hash.startsWith("{SSHA}") ? hash.substring("{SSHA}".length()) : hash;
|
||||||
|
val decodedHash = (isBase64(cleanHash) ? Base64.getDecoder().decode(cleanHash) : cleanHash.getBytes(UTF_8));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// SSHA format: first 20 bytes are the SHA-1 hash, remaining bytes are the salt
|
||||||
|
if (decodedHash.length <= 20) {
|
||||||
|
throw new IllegalArgumentException("Invalid SSHA hash format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the original hash (first 20 bytes)
|
||||||
|
val originalHash = new byte[20];
|
||||||
|
System.arraycopy(decodedHash, 0, originalHash, 0, 20);
|
||||||
|
|
||||||
|
// Extract the salt (remaining bytes)
|
||||||
|
val salt = new byte[decodedHash.length - 20];
|
||||||
|
System.arraycopy(decodedHash, 20, salt, 0, salt.length);
|
||||||
|
|
||||||
|
// Standard verification as fallback
|
||||||
|
digest.reset();
|
||||||
|
digest.update(password.getBytes(UTF_8));
|
||||||
|
digest.update(salt);
|
||||||
|
val passwordHashBytes = digest.digest();
|
||||||
|
|
||||||
|
if (!java.util.Arrays.equals(passwordHashBytes, originalHash)) {
|
||||||
|
throw new IllegalArgumentException("invalid password");
|
||||||
|
}
|
||||||
|
} catch (final Exception e) {
|
||||||
|
throw new IllegalArgumentException("invalid password", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValid(final String password) {
|
||||||
|
return password.startsWith("{SSHA}") && isBase64(password.substring("{SSHA}".length()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
package net.hostsharing.hsadminng.hs.accounts;
|
package net.hostsharing.hsadminng.hs.accounts;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.function.BiConsumer;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import io.micrometer.core.annotation.Timed;
|
import io.micrometer.core.annotation.Timed;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import lombok.val;
|
import lombok.val;
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
|
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource;
|
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource;
|
|
||||||
import net.hostsharing.hsadminng.config.MessageTranslator;
|
|
||||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ProfileApi;
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ProfileApi;
|
||||||
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource;
|
||||||
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource;
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileInsertResource;
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileInsertResource;
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource;
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource;
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileResource;
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileResource;
|
||||||
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource;
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource;
|
||||||
|
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
|
||||||
|
import net.hostsharing.hsadminng.config.MessageTranslator;
|
||||||
|
import net.hostsharing.hsadminng.errors.ForbiddenException;
|
||||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
|
||||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
|
||||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
|
||||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||||
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||||
|
import net.hostsharing.hsadminng.rbac.context.Context;
|
||||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
||||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
||||||
|
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -34,6 +31,10 @@ import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBui
|
|||||||
|
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
import jakarta.validation.ValidationException;
|
import jakarta.validation.ValidationException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static java.util.Optional.of;
|
import static java.util.Optional.of;
|
||||||
|
|
||||||
@@ -51,9 +52,6 @@ public class HsProfileController implements ProfileApi {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private StrictMapper mapper;
|
private StrictMapper mapper;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RbacSubjectRepository subjectRepo;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ScopeResourceToEntityMapper scopeMapper;
|
private ScopeResourceToEntityMapper scopeMapper;
|
||||||
|
|
||||||
@@ -159,6 +157,7 @@ public class HsProfileController implements ProfileApi {
|
|||||||
|
|
||||||
val current = profileRepo.findByUuid(profileUuid).orElseThrow();
|
val current = profileRepo.findByUuid(profileUuid).orElseThrow();
|
||||||
|
|
||||||
|
validateBeforePatch(current, body);
|
||||||
new HsProfileEntityPatcher(scopeMapper, current).apply(body);
|
new HsProfileEntityPatcher(scopeMapper, current).apply(body);
|
||||||
validateOnUpdate(current);
|
validateOnUpdate(current);
|
||||||
|
|
||||||
@@ -188,6 +187,11 @@ public class HsProfileController implements ProfileApi {
|
|||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateBeforePatch(final HsProfileEntity current, final ProfilePatchResource body) {
|
||||||
|
if (!context.isGlobalAdmin() && !current.isActive() && body.getActive())
|
||||||
|
throw new ForbiddenException("Only global admins are allowed to activate an inactive profile");
|
||||||
|
}
|
||||||
|
|
||||||
private void validateOnCreate(final HsProfileEntity newProfileEntity) {
|
private void validateOnCreate(final HsProfileEntity newProfileEntity) {
|
||||||
validateReferencedPersonToBeRepresentedByLoginUserPerson(newProfileEntity);
|
validateReferencedPersonToBeRepresentedByLoginUserPerson(newProfileEntity);
|
||||||
validateNormalUsersOnlyAccessPublicScopes(newProfileEntity);
|
validateNormalUsersOnlyAccessPublicScopes(newProfileEntity);
|
||||||
@@ -276,10 +280,10 @@ public class HsProfileController implements ProfileApi {
|
|||||||
.collect(Collectors.joining(", "));
|
.collect(Collectors.joining(", "));
|
||||||
}
|
}
|
||||||
|
|
||||||
private RbacSubjectEntity createSubject(final String nickname) {
|
private RealSubjectEntity createSubject(final String nickname) {
|
||||||
val rbacSubjectEntity = new RbacSubjectEntity(null, nickname);
|
val rbacSubjectEntity = RbacSubjectEntity.builder().name(nickname).build();
|
||||||
val newRbacSubject = subjectRepo.create(rbacSubjectEntity);
|
val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity);
|
||||||
return newRbacSubject;
|
return em.find(RealSubjectEntity.class, newRbacSubject.getUuid());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<HsProfileEntity> findByPersonUuid(final UUID personUuid) {
|
private List<HsProfileEntity> findByPersonUuid(final UUID personUuid) {
|
||||||
@@ -331,9 +335,8 @@ public class HsProfileController implements ProfileApi {
|
|||||||
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
|
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes()));
|
|
||||||
|
|
||||||
entity.setPerson(person);
|
entity.setPerson(person);
|
||||||
|
entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes()));
|
||||||
|
entity.setPassword(resource.getPassword());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package net.hostsharing.hsadminng.hs.accounts;
|
package net.hostsharing.hsadminng.hs.accounts;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.ValidationException;
|
||||||
|
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
import net.hostsharing.hsadminng.hash.HashGenerator;
|
||||||
|
import net.hostsharing.hsadminng.hash.LdapArgon2Hash;
|
||||||
|
import net.hostsharing.hsadminng.hash.LdapSshaHash;
|
||||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||||
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
|
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
|
||||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||||
import net.hostsharing.hsadminng.repr.Stringify;
|
import net.hostsharing.hsadminng.repr.Stringify;
|
||||||
import net.hostsharing.hsadminng.repr.Stringifyable;
|
import net.hostsharing.hsadminng.repr.Stringifyable;
|
||||||
// import net.hostsharing.hsadminng.rbac.RbacSubjectEntity; // Assuming RbacSubjectEntity exists for the FK relationship
|
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -41,7 +45,7 @@ public class HsProfileEntity implements BaseEntity<HsProfileEntity>, Stringifyab
|
|||||||
@MapsId
|
@MapsId
|
||||||
@OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
|
@OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||||
private RbacSubjectEntity subject;
|
private RealSubjectEntity subject;
|
||||||
|
|
||||||
@ManyToOne(optional = false, fetch = FetchType.EAGER)
|
@ManyToOne(optional = false, fetch = FetchType.EAGER)
|
||||||
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||||
@@ -59,6 +63,9 @@ public class HsProfileEntity implements BaseEntity<HsProfileEntity>, Stringifyab
|
|||||||
@Column
|
@Column
|
||||||
private Integer globalGid;
|
private Integer globalGid;
|
||||||
|
|
||||||
|
@Column(name = "password_hash")
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private List<String> totpSecrets;
|
private List<String> totpSecrets;
|
||||||
|
|
||||||
@@ -86,11 +93,24 @@ public class HsProfileEntity implements BaseEntity<HsProfileEntity>, Stringifyab
|
|||||||
return scopes;
|
return scopes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSubject(final RbacSubjectEntity subject) {
|
public void setSubject(final RealSubjectEntity subject) {
|
||||||
this.uuid = subject.getUuid();
|
this.uuid = subject.getUuid();
|
||||||
this.subject = subject;
|
this.subject = subject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setPassword(final String password) {
|
||||||
|
setPasswordHash(
|
||||||
|
HashGenerator.fromEnv("ACCOUNT_PROFILE_PASSWORD_HASH_ALGORITHM", "{SSHA}")
|
||||||
|
.withRandomSalt().hash(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(final String passwordHash) {
|
||||||
|
if (passwordHash != null) {
|
||||||
|
validatePasswordHash(passwordHash);
|
||||||
|
}
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toShortString() {
|
public String toShortString() {
|
||||||
return active + ":" + emailAddress + ":" + globalUid;
|
return active + ":" + emailAddress + ":" + globalUid;
|
||||||
@@ -101,4 +121,10 @@ public class HsProfileEntity implements BaseEntity<HsProfileEntity>, Stringifyab
|
|||||||
return stringify.apply(this);
|
return stringify.apply(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void validatePasswordHash(final String passwordHash) {
|
||||||
|
|
||||||
|
if (!LdapSshaHash.isValid(passwordHash) && !LdapArgon2Hash.isValid(passwordHash)) {
|
||||||
|
throw new ValidationException("passwordHash must be SSHA or ARGON2 hash valid for LDAP");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,20 +18,20 @@ public class HsProfileEntityPatcher implements EntityPatcher<ProfilePatchResourc
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void apply(final ProfilePatchResource resource) {
|
public void apply(final ProfilePatchResource resource) {
|
||||||
if ( resource.getActive() != null ) {
|
Optional.ofNullable(resource.getActive())
|
||||||
entity.setActive(resource.getActive());
|
.ifPresent(entity::setActive);
|
||||||
}
|
|
||||||
OptionalFromJson.of(resource.getEmailAddress())
|
OptionalFromJson.of(resource.getEmailAddress())
|
||||||
.ifPresent(entity::setEmailAddress);
|
.ifPresent(entity::setEmailAddress);
|
||||||
Optional.ofNullable(resource.getTotpSecrets())
|
Optional.ofNullable(resource.getTotpSecrets())
|
||||||
.ifPresent(entity::setTotpSecrets);
|
.ifPresent(entity::setTotpSecrets);
|
||||||
OptionalFromJson.of(resource.getSmsNumber())
|
OptionalFromJson.of(resource.getSmsNumber())
|
||||||
.ifPresent(entity::setSmsNumber);
|
.ifPresent(entity::setSmsNumber);
|
||||||
|
Optional.ofNullable(resource.getPassword())
|
||||||
|
.ifPresent(entity::setPassword);
|
||||||
OptionalFromJson.of(resource.getPhonePassword())
|
OptionalFromJson.of(resource.getPhonePassword())
|
||||||
.ifPresent(entity::setPhonePassword);
|
.ifPresent(entity::setPhonePassword);
|
||||||
if (resource.getScopes() != null) {
|
if (resource.getScopes() != null) {
|
||||||
scopeMapper.syncProfileScopeEntities(resource.getScopes(), entity.getScopes());
|
scopeMapper.syncProfileScopeEntities(resource.getScopes(), entity.getScopes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,26 @@
|
|||||||
package net.hostsharing.hsadminng.rbac.subject;
|
package net.hostsharing.hsadminng.rbac.subject;
|
||||||
|
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import net.hostsharing.hsadminng.persistence.ImmutableBaseEntity;
|
import lombok.experimental.SuperBuilder;
|
||||||
import org.springframework.data.annotation.Immutable;
|
import org.springframework.data.annotation.Immutable;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeOverride;
|
||||||
|
import jakarta.persistence.AttributeOverrides;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(schema = "rbac", name = "subject_rv")
|
@Table(schema = "rbac", name = "subject_rv")
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@Builder
|
@SuperBuilder(toBuilder = true)
|
||||||
@ToString
|
@ToString(callSuper = true)
|
||||||
@Immutable
|
@Immutable
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AttributeOverrides({
|
||||||
public class RbacSubjectEntity implements ImmutableBaseEntity<RbacSubjectEntity> {
|
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
|
||||||
|
})
|
||||||
private static final int MAX_VALIDITY_DAYS = 21;
|
public class RbacSubjectEntity extends Subject<RbacSubjectEntity> {
|
||||||
private static DateTimeFormatter DATE_FORMAT_WITH_FULLHOUR = DateTimeFormatter.ofPattern("MM-dd-yyyy HH");
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue
|
|
||||||
private UUID uuid;
|
|
||||||
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
public String generateAccessCode() {
|
|
||||||
return generateAccessCode(LocalDateTime.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isValidAccessCode(final String accessCode, final int validityHours) {
|
|
||||||
if (validityHours > 24 * MAX_VALIDITY_DAYS) {
|
|
||||||
throw new IllegalArgumentException("Max validity (%s days) exceeded.".formatted(MAX_VALIDITY_DAYS));
|
|
||||||
}
|
|
||||||
if (generateAccessCode(LocalDateTime.now().minus(validityHours, ChronoUnit.HOURS)).equals(accessCode)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (validityHours < 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return isValidAccessCode(accessCode, validityHours - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
String generateAccessCode(final LocalDateTime timestamp) {
|
|
||||||
final var compound = name + ":" + uuid + ":" + timestamp.format(DATE_FORMAT_WITH_FULLHOUR);
|
|
||||||
final var code = String.valueOf(1000000 + Math.abs(compound.hashCode()) % 100000);
|
|
||||||
return code.substring(1, 4) + ":" + code.substring(4, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,19 +15,19 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
|
|||||||
where :userName is null or u.name like concat(cast(:userName as text), '%')
|
where :userName is null or u.name like concat(cast(:userName as text), '%')
|
||||||
order by u.name
|
order by u.name
|
||||||
""")
|
""")
|
||||||
@Timed("app.rbac.subjects.repo.findByOptionalNameLike")
|
@Timed("app.rbac.subjects.repo.findByOptionalNameLike.rbac")
|
||||||
List<RbacSubjectEntity> findByOptionalNameLike(String userName);
|
List<RbacSubjectEntity> findByOptionalNameLike(String userName);
|
||||||
|
|
||||||
// bypasses the restricted view, to be able to grant rights to arbitrary user
|
// bypasses the restricted view, to be able to grant rights to arbitrary user
|
||||||
@Query(value = "select * from rbac.subject where name=:userName", nativeQuery = true)
|
@Query(value = "select * from rbac.subject where name=:userName", nativeQuery = true)
|
||||||
@Timed("app.rbac.subjects.repo.findByName")
|
@Timed("app.rbac.subjects.repo.findByName.rbac")
|
||||||
RbacSubjectEntity findByName(String userName);
|
RbacSubjectEntity findByName(String userName);
|
||||||
|
|
||||||
@Timed("app.rbac.subjects.repo.findByUuid")
|
@Timed("app.rbac.subjects.repo.findByUuid.rbac")
|
||||||
RbacSubjectEntity findByUuid(UUID uuid);
|
RbacSubjectEntity findByUuid(UUID uuid);
|
||||||
|
|
||||||
@Query(value = "select * from rbac.grantedPermissions(:subjectUuid)", nativeQuery = true)
|
@Query(value = "select * from rbac.grantedPermissions(:subjectUuid)", nativeQuery = true)
|
||||||
@Timed("app.rbac.subjects.repo.findPermissionsOfUserByUuid")
|
@Timed("app.rbac.subjects.repo.findPermissionsOfUserByUuid.rbac")
|
||||||
List<RbacSubjectPermission> findPermissionsOfUserByUuid(UUID subjectUuid);
|
List<RbacSubjectPermission> findPermissionsOfUserByUuid(UUID subjectUuid);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -37,7 +37,7 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
|
|||||||
*/
|
*/
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query(value = "insert into rbac.subject_rv (uuid, name) values( :#{#newUser.uuid}, :#{#newUser.name})", nativeQuery = true)
|
@Query(value = "insert into rbac.subject_rv (uuid, name) values( :#{#newUser.uuid}, :#{#newUser.name})", nativeQuery = true)
|
||||||
@Timed("app.rbac.subjects.repo.insert")
|
@Timed("app.rbac.subjects.repo.insert.rbac")
|
||||||
void insert(final RbacSubjectEntity newUser);
|
void insert(final RbacSubjectEntity newUser);
|
||||||
|
|
||||||
default RbacSubjectEntity create(final RbacSubjectEntity rbacSubjectEntity) {
|
default RbacSubjectEntity create(final RbacSubjectEntity rbacSubjectEntity) {
|
||||||
@@ -52,6 +52,6 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
|
|||||||
return rbacSubjectEntity; // Not yet attached to EM!
|
return rbacSubjectEntity; // Not yet attached to EM!
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed("app.rbac.subjects.repo.deleteByUuid")
|
@Timed("app.rbac.subjects.repo.deleteByUuid.rbac")
|
||||||
void deleteByUuid(UUID subjectUuid);
|
void deleteByUuid(UUID subjectUuid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package net.hostsharing.hsadminng.rbac.subject;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.ToString;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import org.springframework.data.annotation.Immutable;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeOverride;
|
||||||
|
import jakarta.persistence.AttributeOverrides;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "rbac", name = "subject")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
@ToString(callSuper = true)
|
||||||
|
@Immutable
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AttributeOverrides({
|
||||||
|
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
|
||||||
|
})
|
||||||
|
public class RealSubjectEntity extends Subject<RealSubjectEntity> {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package net.hostsharing.hsadminng.rbac.subject;
|
||||||
|
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import lombok.val;
|
||||||
|
import net.hostsharing.hsadminng.persistence.ImmutableBaseEntity;
|
||||||
|
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.MappedSuperclass;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
public abstract class Subject<T extends Subject<?> & ImmutableBaseEntity<?>> implements ImmutableBaseEntity<T> {
|
||||||
|
|
||||||
|
private static final int MAX_VALIDITY_DAYS = 21;
|
||||||
|
private static final DateTimeFormatter DATE_FORMAT_WITH_FULLHOUR = DateTimeFormatter.ofPattern("MM-dd-yyyy HH");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private UUID uuid;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
public String generateAccessCode() {
|
||||||
|
return generateAccessCode(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValidAccessCode(final String accessCode, final int validityHours) {
|
||||||
|
if (validityHours > 24 * MAX_VALIDITY_DAYS) {
|
||||||
|
throw new IllegalArgumentException("Max validity (%s days) exceeded.".formatted(MAX_VALIDITY_DAYS));
|
||||||
|
}
|
||||||
|
if (generateAccessCode(LocalDateTime.now().minus(validityHours, ChronoUnit.HOURS)).equals(accessCode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (validityHours < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isValidAccessCode(accessCode, validityHours - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String generateAccessCode(final LocalDateTime timestamp) {
|
||||||
|
val compound = name + ":" + uuid + ":" + timestamp.format(DATE_FORMAT_WITH_FULLHOUR);
|
||||||
|
val code = String.valueOf(1000000 + Math.abs(compound.hashCode()) % 100000);
|
||||||
|
return code.substring(1, 4) + ":" + code.substring(4, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,16 +24,16 @@ components:
|
|||||||
nickname:
|
nickname:
|
||||||
type: string
|
type: string
|
||||||
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
|
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
|
||||||
|
emailAddress:
|
||||||
|
type: string
|
||||||
|
smsNumber:
|
||||||
|
type: string
|
||||||
totpSecrets:
|
totpSecrets:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
phonePassword:
|
phonePassword:
|
||||||
type: string
|
type: string
|
||||||
emailAddress:
|
|
||||||
type: string
|
|
||||||
smsNumber:
|
|
||||||
type: string
|
|
||||||
active:
|
active:
|
||||||
type: boolean
|
type: boolean
|
||||||
globalUid:
|
globalUid:
|
||||||
@@ -53,19 +53,23 @@ components:
|
|||||||
ProfilePatch:
|
ProfilePatch:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
totpSecrets:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
phonePassword:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
emailAddress:
|
emailAddress:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
smsNumber:
|
smsNumber:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
totpSecrets:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
description: plaintext password or valid hash
|
||||||
|
phonePassword:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
active:
|
active:
|
||||||
type: boolean
|
type: boolean
|
||||||
scopes:
|
scopes:
|
||||||
@@ -93,6 +97,10 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
globalGid:
|
globalGid:
|
||||||
type: number
|
type: number
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
description: plaintext password or valid hash
|
||||||
phonePassword:
|
phonePassword:
|
||||||
type: string
|
type: string
|
||||||
totpSecrets:
|
totpSecrets:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ create table hs_accounts.profile
|
|||||||
global_uid int unique, -- w/o
|
global_uid int unique, -- w/o
|
||||||
global_gid int unique, -- w/o
|
global_gid int unique, -- w/o
|
||||||
|
|
||||||
|
password_hash text,
|
||||||
totp_secrets text[],
|
totp_secrets text[],
|
||||||
phone_password text,
|
phone_password text,
|
||||||
email_address text,
|
email_address text,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import lombok.val;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class Base64UtilsUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testNullInput() {
|
||||||
|
val given = (String) null;
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Null input should not be valid Base64").isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testEmptyInput() {
|
||||||
|
val given = "";
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Empty string should not be valid Base64").isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testValidBase64WithoutPadding() {
|
||||||
|
val given = "U29tZU5vbmRLZXk"; // 'SomeNonKey' in Base64
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Valid Base64 string without padding should be identified as valid").isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testValidBase64WithPadding() {
|
||||||
|
val given = "U29tZSBLZXk="; // 'Some Key' in Base64
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Valid Base64 string with padding should be identified as valid").isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testValidBase64WithDoublePadding() {
|
||||||
|
val given = "U29tZQ=="; // 'Some' in Base64
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Valid Base64 string with double padding should be identified as valid").isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testInvalidBase64LengthNotDivisibleByFour() {
|
||||||
|
val given = "U29tZQ="; // Invalid length for Base64 with padding
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Input with invalid length not divisible by 4 should not be valid Base64").isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testInvalidBase64WithSpecialCharacters() {
|
||||||
|
val given = "U29tZQ$$"; // Contains invalid characters
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Input containing invalid characters should not be valid Base64").isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testInvalidBase64WithWhitespace() {
|
||||||
|
val given = "U29tZ SBs"; // Contains whitespace
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Input containing whitespace should not be valid Base64").isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testInvalidBase64WithExcessivePadding() {
|
||||||
|
val given = "U29tZQ==="; // Too many padding characters
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Input with excessive padding should not be valid Base64").isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testEdgeCaseBase64SingleCharacter() {
|
||||||
|
val given = "U"; // Single valid Base64 character
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Single-character input should not be valid Base64").isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testValidBase64EdgeCaseMinimalLengthWithPadding() {
|
||||||
|
val given = "U2="; // Minimal valid Base64 with padding – maps to one byte
|
||||||
|
val actual = Base64Utils.isBase64(given);
|
||||||
|
assertThat(actual).as("Base64 input length not divisible by 4 should fail even if contains padding.").isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package net.hostsharing.hsadminng.hash;
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import lombok.val;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LDAP_ARGON2;
|
||||||
|
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LDAP_SSHA;
|
||||||
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LINUX_SHA512;
|
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LINUX_SHA512;
|
||||||
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LINUX_YESCRYPT;
|
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.LINUX_YESCRYPT;
|
||||||
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.MYSQL_NATIVE;
|
import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.MYSQL_NATIVE;
|
||||||
@@ -12,6 +17,7 @@ import static net.hostsharing.hsadminng.hash.HashGenerator.Algorithm.SCRAM_SHA25
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
class HashGeneratorUnitTest {
|
class HashGeneratorUnitTest {
|
||||||
|
|
||||||
final String GIVEN_PASSWORD = "given password";
|
final String GIVEN_PASSWORD = "given password";
|
||||||
@@ -27,6 +33,89 @@ class HashGeneratorUnitTest {
|
|||||||
// SELECT rolname, rolpassword FROM pg_authid WHERE rolname = 'test';
|
// SELECT rolname, rolpassword FROM pg_authid WHERE rolname = 'test';
|
||||||
final String GIVEN_POSTGRESQL_GENERATED_SCRAM_SHA256_HASH = "SCRAM-SHA-256$4096:m8M12fdSTsKH+ywthTx1Zw==$4vsB1OddRNdsej9NPAFh91MPdtbOPjkQ85LQZS5lV0Q=:NsVpQNx4Ic/8Sqj1dxfBzUAxyF4FCTMpIsI+bOZCTfA=";
|
final String GIVEN_POSTGRESQL_GENERATED_SCRAM_SHA256_HASH = "SCRAM-SHA-256$4096:m8M12fdSTsKH+ywthTx1Zw==$4vsB1OddRNdsej9NPAFh91MPdtbOPjkQ85LQZS5lV0Q=:NsVpQNx4Ic/8Sqj1dxfBzUAxyF4FCTMpIsI+bOZCTfA=";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fetchesHashGeneratorFromEnvVarDefault() {
|
||||||
|
{
|
||||||
|
val hash = HashGenerator.fromEnv("NON_EXISTING_ENV_VAR", "{SSHA}").withRandomSalt().hash(GIVEN_PASSWORD);
|
||||||
|
LdapSshaHash.verifyHash(hash, GIVEN_PASSWORD); // throws exception if wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
val hash = HashGenerator.fromEnv("NON_EXISTING_ENV_VAR", "{ARGON2}").withRandomSalt().hash(GIVEN_PASSWORD);
|
||||||
|
LdapArgon2Hash.verifyHash(hash, GIVEN_PASSWORD); // throws exception if wrong
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifiesPasswordAgainstGeneratedArgon2Hash() {
|
||||||
|
val hash = HashGenerator.using(LDAP_ARGON2).withSalt(null).hash(GIVEN_PASSWORD);
|
||||||
|
LdapArgon2Hash.verifyHash(hash, GIVEN_PASSWORD); // throws exception if wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidPasswordAgainstGeneratedArgon2Hash() {
|
||||||
|
val hash = HashGenerator.using(LDAP_ARGON2).withSalt(null).hash(GIVEN_PASSWORD);
|
||||||
|
final var throwable = catchThrowable(() ->
|
||||||
|
LdapArgon2Hash.verifyHash(hash, GIVEN_PASSWORD+"x") // throws exception if wrong
|
||||||
|
);
|
||||||
|
assertThat(throwable).hasMessage("invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentArgon2AdapterIgnoresExplicitSalt() {
|
||||||
|
val hash = HashGenerator.using(LDAP_ARGON2).withRandomSalt().hash(GIVEN_PASSWORD);
|
||||||
|
LdapArgon2Hash.verifyHash(hash, GIVEN_PASSWORD); // throws exception if wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void avoidsDoubleHashingArgon2AHashPassword() {
|
||||||
|
val hashedPassword = "{ARGON2}$argon2id$v=19$m=65536,t=3,p=1$pEabRksh7EJQV+OwPR5n7Q$83qQtZe2J8+fteWm7g/uvXksfhJKGsipZFsuAaJtBjs";
|
||||||
|
val hash = HashGenerator.using(LDAP_ARGON2).hash(hashedPassword);
|
||||||
|
assertThat(hash).isEqualTo(hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hashesPasswordWhichLooksLikeArgon2AHashButIsNot() {
|
||||||
|
val password = "{ARGON2}$argon2id$das-ist-kein-base64-hash";
|
||||||
|
val hash = HashGenerator.using(LDAP_ARGON2).hash(password);
|
||||||
|
LdapArgon2Hash.verifyHash(hash, password); // throws exception if wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifiesPasswordAgainstGeneratedSshaHash() {
|
||||||
|
val hash = HashGenerator.using(LDAP_SSHA).withRandomSalt().hash(GIVEN_PASSWORD);
|
||||||
|
LdapSshaHash.verifyHash(hash, GIVEN_PASSWORD); // throws exception if wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void avoidsDoubleHashingSshaHashPassword() {
|
||||||
|
val hashedPassword = "{SSHA}SNBnIh5QomfgrvDLDwBR+JOcc8Y17H+4";
|
||||||
|
val hash = HashGenerator.using(LDAP_SSHA).withRandomSalt().hash(hashedPassword);
|
||||||
|
assertThat(hash).isEqualTo(hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hashesPasswordWhichLooksLikeSshaHashButIsNot() {
|
||||||
|
val password = "{SSHA}das-ist-kein-base64-hash";
|
||||||
|
val hash = HashGenerator.using(LDAP_SSHA).withRandomSalt().hash(password);
|
||||||
|
LdapSshaHash.verifyHash(hash, password); // throws exception if wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifiesPasswordAgainstRawSshaHashFromOpenLdap() {
|
||||||
|
val sha512HashFromOpenLdap = "{SSHA}SNBnIh5QomfgrvDLDwBR+JOcc8Y17H+4";
|
||||||
|
LdapSshaHash.verifyHash(sha512HashFromOpenLdap, "QpoGyCeuC1m5X6ew"); // throws exception if wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidPasswordAgainstGeneratedSshaHash() {
|
||||||
|
val hash = HashGenerator.using(LDAP_SSHA).withRandomSalt().hash(GIVEN_PASSWORD);
|
||||||
|
final var throwable = catchThrowable(() ->
|
||||||
|
LdapSshaHash.verifyHash(hash, GIVEN_PASSWORD+"x") // throws exception if wrong
|
||||||
|
);
|
||||||
|
assertThat(throwable).hasMessage("invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifiesLinuxPasswordAgainstSha512HashFromMkpasswd() {
|
void verifiesLinuxPasswordAgainstSha512HashFromMkpasswd() {
|
||||||
LinuxEtcShadowHashGenerator.verify(GIVEN_LINUX_GENERATED_SHA512_HASH, GIVEN_PASSWORD); // throws exception if wrong
|
LinuxEtcShadowHashGenerator.verify(GIVEN_LINUX_GENERATED_SHA512_HASH, GIVEN_PASSWORD); // throws exception if wrong
|
||||||
|
|||||||
+57
-5
@@ -9,6 +9,7 @@ import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
|||||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
|
||||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
||||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
||||||
|
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||||
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
|
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
|
||||||
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
@@ -52,7 +53,7 @@ class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup {
|
|||||||
Context context;
|
Context context;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
RbacSubjectRepository subjectRepo;
|
RbacSubjectRepository rbacSubjectRepo;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
HsOfficePersonRealRepository realPersonRepo;
|
HsOfficePersonRealRepository realPersonRepo;
|
||||||
@@ -361,9 +362,59 @@ class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup {
|
|||||||
.body("message", containsString("die eigenen hsadmin-Profile dürfen nicht entfernt werden"));
|
.body("message", containsString("die eigenen hsadmin-Profile dürfen nicht entfernt werden"));
|
||||||
// @formatter:on
|
// @formatter:on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectActivatingProfileForNormalUser() {
|
||||||
|
// given
|
||||||
|
context.define("selfregistered-user-drew@hostsharing.org");
|
||||||
|
val drewProfile = profileRepo.findByCurrentSubject().stream().findFirst().orElseThrow();
|
||||||
|
val inactiveProfileUuid = createNewInactiveProfile(drewProfile.getPerson()).getSubject().getUuid();
|
||||||
|
|
||||||
|
RestAssured // @formatter:off
|
||||||
|
.given()
|
||||||
|
.header("Authorization", bearer("selfregistered-user-drew@hostsharing.org"))
|
||||||
|
.header("Accept-Language", "de")
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("""
|
||||||
|
{
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
.port(port)
|
||||||
|
.when()
|
||||||
|
.patch("http://localhost/api/hs/accounts/profiles/" + inactiveProfileUuid)
|
||||||
|
.then().log().all().assertThat()
|
||||||
|
.statusCode(403)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("message", containsString("Only global admins are allowed to activate an inactive profile"));
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
|
|
||||||
|
private HsProfileEntity createNewInactiveProfile(final HsOfficePersonRealEntity person) {
|
||||||
|
return jpaAttempt.transacted(() -> {
|
||||||
|
context.define("superuser-alex@hostsharing.net");
|
||||||
|
// only RbacSubject entities can be created
|
||||||
|
val rbacSubjectEntity = rbacSubjectRepo.create(RbacSubjectEntity.builder()
|
||||||
|
.name("some-inactive-profile")
|
||||||
|
.build());
|
||||||
|
// but we need the RealSubjectEntity to be attached to the profile entity
|
||||||
|
val realSubjectEntity = em.find(RealSubjectEntity.class, rbacSubjectEntity.getUuid());
|
||||||
|
|
||||||
|
val inactiveCopy = HsProfileEntity.builder()
|
||||||
|
.person(person)
|
||||||
|
.subject(realSubjectEntity)
|
||||||
|
.active(false).build();
|
||||||
|
em.persist(inactiveCopy);
|
||||||
|
em.flush();
|
||||||
|
return toCleanup(inactiveCopy);
|
||||||
|
}).assertSuccessful().returnedValue();
|
||||||
|
}
|
||||||
|
|
||||||
private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) {
|
private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) {
|
||||||
return jpaAttempt.transacted(() -> {
|
return jpaAttempt.transacted(() -> {
|
||||||
context.define(executingSubjectName);
|
context.define(executingSubjectName);
|
||||||
@@ -418,16 +469,17 @@ class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup {
|
|||||||
) {
|
) {
|
||||||
return jpaAttempt.transacted(() -> {
|
return jpaAttempt.transacted(() -> {
|
||||||
context.define(executingSubjectName);
|
context.define(executingSubjectName);
|
||||||
final RbacSubjectEntity rbacSubjectEntity = RbacSubjectEntity.builder()
|
|
||||||
|
// only RbacSubject entities can be created
|
||||||
|
val subject = rbacSubjectRepo.create(RbacSubjectEntity.builder()
|
||||||
.name(newSubjectName)
|
.name(newSubjectName)
|
||||||
.build();
|
.build());
|
||||||
val subject = subjectRepo.create(rbacSubjectEntity);
|
|
||||||
|
|
||||||
context.define(subject.getName());
|
context.define(subject.getName());
|
||||||
val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid());
|
val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid());
|
||||||
val profileBuilder = HsProfileEntity.builder()
|
val profileBuilder = HsProfileEntity.builder()
|
||||||
.person(attachedPerson)
|
.person(attachedPerson)
|
||||||
.subject(subjectRepo.findByUuid(subject.getUuid()))
|
.subject(em.find(RealSubjectEntity.class, subject.getUuid()))
|
||||||
.scopes(Set.of());
|
.scopes(Set.of());
|
||||||
modifier.accept(profileBuilder);
|
modifier.accept(profileBuilder);
|
||||||
return toCleanup(profileRepo.save(profileBuilder.build()));
|
return toCleanup(profileRepo.save(profileBuilder.build()));
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.accounts;
|
||||||
|
|
||||||
|
import lombok.val;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
|
||||||
|
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import jakarta.validation.ValidationException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class HsProfileEntityUnitTest {
|
||||||
|
|
||||||
|
static final HsProfileEntity GIVEN_PROFILE_ENTITY = HsProfileEntity.builder()
|
||||||
|
.uuid(UUID.fromString("11111111-1111-1111-1111-111111111111"))
|
||||||
|
.subject(
|
||||||
|
RealSubjectEntity.builder().uuid(UUID.randomUUID()).name("testSubject").build())
|
||||||
|
.person(
|
||||||
|
HsOfficePersonRealEntity.builder()
|
||||||
|
.personType(HsOfficePersonType.NATURAL_PERSON)
|
||||||
|
.familyName("Miller")
|
||||||
|
.givenName("John")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.emailAddress("john.miller@example.com")
|
||||||
|
.smsNumber("+49 1234567890")
|
||||||
|
.globalUid(10001)
|
||||||
|
.globalUid(20002)
|
||||||
|
.phonePassword("hello world")
|
||||||
|
.totpSecrets(List.of("secret1", "secret2"))
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toShortStringContainsJustTypeAndQualifier() {
|
||||||
|
assertThat(GIVEN_PROFILE_ENTITY.toShortString()).isEqualTo("true:john.miller@example.com:20002");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toStringContainsAllPropertiesExceptUuidAndPasswordHash() {
|
||||||
|
assertThat(GIVEN_PROFILE_ENTITY.toString()).isEqualTo("profile(true, john.miller@example.com, [secret1, secret2], hello world, +49 1234567890)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setPasswordSetsPasswordHash() {
|
||||||
|
val profile = HsProfileEntity.builder().build();
|
||||||
|
profile.setPassword("my password");
|
||||||
|
assertThat(profile.getPasswordHash()).startsWith("{SSHA}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsValidSshaPasswordHash() {
|
||||||
|
val givenSshaHash = "{SSHA}SNBnIh5QomfgrvDLDwBR+JOcc8Y17H+4";
|
||||||
|
val profile = HsProfileEntity.builder().build();
|
||||||
|
profile.setPasswordHash(givenSshaHash);
|
||||||
|
assertThat(profile.getPasswordHash()).isEqualTo(givenSshaHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsValidArgon2PasswordHash() {
|
||||||
|
val givenArgon2Hash = "{ARGON2}$argon2id$v=19$m=65536,t=3,p=1$pEabRksh7EJQV+OwPR5n7Q$83qQtZe2J8+fteWm7g/uvXksfhJKGsipZFsuAaJtBjs";
|
||||||
|
val profile = HsProfileEntity.builder().build();
|
||||||
|
profile.setPasswordHash(givenArgon2Hash);
|
||||||
|
assertThat(profile.getPasswordHash()).isEqualTo(givenArgon2Hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectInvalidPasswordHash() {
|
||||||
|
val profile = HsProfileEntity.builder().build();
|
||||||
|
val throwable = assertThrows(
|
||||||
|
ValidationException.class,
|
||||||
|
() -> profile.setPasswordHash("{whatever} but not a valid hash"));
|
||||||
|
assertThat(throwable.getMessage()).isEqualTo("passwordHash must be SSHA or ARGON2 hash valid for LDAP");
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-16
@@ -1,5 +1,6 @@
|
|||||||
package net.hostsharing.hsadminng.hs.accounts;
|
package net.hostsharing.hsadminng.hs.accounts;
|
||||||
|
|
||||||
|
import lombok.val;
|
||||||
import net.hostsharing.hsadminng.rbac.context.Context;
|
import net.hostsharing.hsadminng.rbac.context.Context;
|
||||||
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
|
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
|
||||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||||
@@ -7,6 +8,8 @@ import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
|
|||||||
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
|
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
|
||||||
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType;
|
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType;
|
||||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
||||||
|
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
|
||||||
|
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
|
||||||
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
|
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
|
||||||
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
||||||
import org.hibernate.TransientObjectException;
|
import org.hibernate.TransientObjectException;
|
||||||
@@ -48,6 +51,9 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
@MockitoBean
|
@MockitoBean
|
||||||
HttpServletRequest request;
|
HttpServletRequest request;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RbacSubjectRepository rbacSubjectRepo;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private HsOfficePersonRealRepository personRepo;
|
private HsOfficePersonRealRepository personRepo;
|
||||||
|
|
||||||
@@ -58,9 +64,9 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
private HsProfileScopeRealRepository scopeRealRepo;
|
private HsProfileScopeRealRepository scopeRealRepo;
|
||||||
|
|
||||||
// fetched UUIDs from test-data
|
// fetched UUIDs from test-data
|
||||||
private RbacSubjectEntity alexSubject;
|
private RealSubjectEntity alexSubject;
|
||||||
private RbacSubjectEntity drewSubject;
|
private RealSubjectEntity drewSubject;
|
||||||
private RbacSubjectEntity testUserSubject;
|
private RealSubjectEntity testUserSubject;
|
||||||
private HsOfficePersonRealEntity drewPerson;
|
private HsOfficePersonRealEntity drewPerson;
|
||||||
private HsOfficePersonRealEntity testUserPerson;
|
private HsOfficePersonRealEntity testUserPerson;
|
||||||
|
|
||||||
@@ -106,11 +112,13 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
givenRelation(REPRESENTATIVE)
|
givenRelation(REPRESENTATIVE)
|
||||||
.withAnchorPersonLike(firstGmbHPerson)
|
.withAnchorPersonLike(firstGmbHPerson)
|
||||||
.withHolder(drewPerson)
|
.withHolder(drewPerson)
|
||||||
.withContact("some test contact");
|
.withContact("some test contact")
|
||||||
|
.inDatabase();
|
||||||
givenProfile()
|
givenProfile()
|
||||||
.forSubject("first-gmbh")
|
.forSubject("first-gmbh")
|
||||||
.forPerson(firstGmbHPerson)
|
.forPerson(firstGmbHPerson)
|
||||||
.withEMailAddress("first-gmbh@example.com");
|
.withEMailAddress("first-gmbh@example.com")
|
||||||
|
.inDatabase();
|
||||||
|
|
||||||
// when
|
// when
|
||||||
final var foundProfile = attempt(
|
final var foundProfile = attempt(
|
||||||
@@ -263,13 +271,13 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private RbacSubjectEntity fetchSubjectByName(final String name) {
|
private RealSubjectEntity fetchSubjectByName(final String name) {
|
||||||
final String jpql = "SELECT s FROM RbacSubjectEntity s WHERE s.name = :name";
|
final String jpql = "SELECT s FROM RealSubjectEntity s WHERE s.name = :name";
|
||||||
final Query query = em.createQuery(jpql, RbacSubjectEntity.class);
|
final Query query = em.createQuery(jpql, RealSubjectEntity.class);
|
||||||
query.setParameter("name", name);
|
query.setParameter("name", name);
|
||||||
try {
|
try {
|
||||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||||
return notNull((RbacSubjectEntity) query.getSingleResult());
|
return notNull((RealSubjectEntity) query.getSingleResult());
|
||||||
} catch (final NoResultException e) {
|
} catch (final NoResultException e) {
|
||||||
throw new AssertionError(
|
throw new AssertionError(
|
||||||
"Failed to find subject with name '" + name + "'. Ensure test data is present.", e);
|
"Failed to find subject with name '" + name + "'. Ensure test data is present.", e);
|
||||||
@@ -331,10 +339,14 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HsOfficeRelationRealEntity withContact(String caption) {
|
public RelationBuilder withContact(String caption) {
|
||||||
this.contact = HsOfficeContactRealEntity.builder()
|
this.contact = HsOfficeContactRealEntity.builder()
|
||||||
.caption(caption)
|
.caption(caption)
|
||||||
.build();
|
.build();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HsOfficeRelationRealEntity inDatabase() {
|
||||||
em.persist(contact);
|
em.persist(contact);
|
||||||
|
|
||||||
final var relation = HsOfficeRelationRealEntity.builder()
|
final var relation = HsOfficeRelationRealEntity.builder()
|
||||||
@@ -350,15 +362,17 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class ProfileBuilder {
|
private class ProfileBuilder {
|
||||||
private RbacSubjectEntity subject;
|
private RealSubjectEntity subject;
|
||||||
private HsOfficePersonRealEntity person;
|
private HsOfficePersonRealEntity person;
|
||||||
|
private String emailAddress;
|
||||||
|
|
||||||
public ProfileBuilder forSubject(String subjectName) {
|
public ProfileBuilder forSubject(String subjectName) {
|
||||||
this.subject = RbacSubjectEntity.builder()
|
// only the RbacSubject can be created
|
||||||
|
val rbacSubject = toCleanup(rbacSubjectRepo.create(RbacSubjectEntity.builder()
|
||||||
.name(subjectName)
|
.name(subjectName)
|
||||||
.build();
|
.build()));
|
||||||
em.persist(subject);
|
// but we need the RealSubject
|
||||||
toCleanup(subject);
|
this.subject = em.find(RealSubjectEntity.class, rbacSubject.getUuid());
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +381,19 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HsProfileEntity withEMailAddress(String emailAddress) {
|
public ProfileBuilder withEMailAddress(String emailAddress) {
|
||||||
|
this.emailAddress = emailAddress;
|
||||||
|
final var profile = HsProfileEntity.builder()
|
||||||
|
.uuid(subject.getUuid())
|
||||||
|
.subject(subject)
|
||||||
|
.person(em.find(HsOfficePersonRealEntity.class, person.getUuid()))
|
||||||
|
.emailAddress(emailAddress)
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HsProfileEntity inDatabase() {
|
||||||
|
|
||||||
final var profile = HsProfileEntity.builder()
|
final var profile = HsProfileEntity.builder()
|
||||||
.uuid(subject.getUuid())
|
.uuid(subject.getUuid())
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ public class CreateProfile extends BaseProfileUseCase<CreateProfile> {
|
|||||||
{
|
{
|
||||||
"person.uuid": ${Person: %{personGivenName} %{personFamilyName}},
|
"person.uuid": ${Person: %{personGivenName} %{personFamilyName}},
|
||||||
"nickname": ${nickname},
|
"nickname": ${nickname},
|
||||||
"active": %{active},
|
|
||||||
"totpSecrets": @{totpSecrets},
|
|
||||||
"emailAddress": ${emailAddress},
|
"emailAddress": ${emailAddress},
|
||||||
"phonePassword": ${phonePassword},
|
|
||||||
"smsNumber": ${smsNumber},
|
"smsNumber": ${smsNumber},
|
||||||
|
"password": ${password},
|
||||||
|
"totpSecrets": @{totpSecrets},
|
||||||
|
"phonePassword": ${phonePassword},
|
||||||
"globalUid": %{globalUid},
|
"globalUid": %{globalUid},
|
||||||
"globalGid": %{globalGid},
|
"globalGid": %{globalGid},
|
||||||
|
"active": %{active},
|
||||||
"scopes": @{resolvedScopes}
|
"scopes": @{resolvedScopes}
|
||||||
}
|
}
|
||||||
"""))
|
"""))
|
||||||
|
|||||||
+5
-3
@@ -74,13 +74,14 @@ class ProfileScenarioTests extends ScenarioTest {
|
|||||||
// a login name, to be stored in the new RBAC subject
|
// a login name, to be stored in the new RBAC subject
|
||||||
.given("nickname", "firby-susan")
|
.given("nickname", "firby-susan")
|
||||||
// initial profile
|
// initial profile
|
||||||
.given("active", true)
|
|
||||||
.given("totpSecrets", Array.of("initialSecret"))
|
|
||||||
.given("emailAddress", "susan.firby@example.com")
|
.given("emailAddress", "susan.firby@example.com")
|
||||||
.given("phonePassword", "securePass123")
|
|
||||||
.given("smsNumber", "+49123456789")
|
.given("smsNumber", "+49123456789")
|
||||||
|
.given("password", "my raw password")
|
||||||
|
.given("totpSecrets", Array.of("initialSecret"))
|
||||||
|
.given("phonePassword", "securePass123")
|
||||||
.given("globalUid", 21011)
|
.given("globalUid", 21011)
|
||||||
.given("globalGid", 21011)
|
.given("globalGid", 21011)
|
||||||
|
.given("active", true)
|
||||||
.given(
|
.given(
|
||||||
"scopes", Array.of(
|
"scopes", Array.of(
|
||||||
Pair.of("HSADMIN", "prod")
|
Pair.of("HSADMIN", "prod")
|
||||||
@@ -100,6 +101,7 @@ class ProfileScenarioTests extends ScenarioTest {
|
|||||||
.given("active", false)
|
.given("active", false)
|
||||||
.given("totpSecrets", Array.of("initialSecret", "additionalSecret"))
|
.given("totpSecrets", Array.of("initialSecret", "additionalSecret"))
|
||||||
.given("emailAddress", "susan.firby@example.org")
|
.given("emailAddress", "susan.firby@example.org")
|
||||||
|
.given("password", "my new raw password")
|
||||||
.given("phonePassword", "securePass987")
|
.given("phonePassword", "securePass987")
|
||||||
.given("smsNumber", "+49987654321")
|
.given("smsNumber", "+49987654321")
|
||||||
.given(
|
.given(
|
||||||
|
|||||||
+3
-2
@@ -24,8 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import jakarta.persistence.PersistenceContext;
|
import jakarta.persistence.PersistenceContext;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
|
import static java.util.UUID.randomUUID;
|
||||||
import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer;
|
import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.hamcrest.CoreMatchers.containsString;
|
import static org.hamcrest.CoreMatchers.containsString;
|
||||||
@@ -491,7 +491,8 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest {
|
|||||||
return jpaAttempt.transacted(() -> {
|
return jpaAttempt.transacted(() -> {
|
||||||
final String newUserName = "test-user-" + RandomStringUtils.randomAlphabetic(8) + "@example.com";
|
final String newUserName = "test-user-" + RandomStringUtils.randomAlphabetic(8) + "@example.com";
|
||||||
context(null);
|
context(null);
|
||||||
return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), newUserName));
|
return rbacSubjectRepository.create(
|
||||||
|
RbacSubjectEntity.builder().uuid(randomUUID()).name(newUserName).build());
|
||||||
}).returnedValue();
|
}).returnedValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -322,13 +322,13 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest {
|
|||||||
return jpaAttempt.transacted(() -> {
|
return jpaAttempt.transacted(() -> {
|
||||||
final var newUserName = "test-user-" + System.currentTimeMillis() + "@example.com";
|
final var newUserName = "test-user-" + System.currentTimeMillis() + "@example.com";
|
||||||
context(null);
|
context(null);
|
||||||
return rbacSubjectRepository.create(new RbacSubjectEntity(null, newUserName));
|
return rbacSubjectRepository.create(RbacSubjectEntity.builder().name(newUserName).build());
|
||||||
}).assumeSuccessful().returnedValue();
|
}).assumeSuccessful().returnedValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private RbacSubjectEntity createNewUser() {
|
private RbacSubjectEntity createNewUser() {
|
||||||
return rbacSubjectRepository.create(
|
return rbacSubjectRepository.create(
|
||||||
new RbacSubjectEntity(null, "test-user-" + System.currentTimeMillis() + "@example.com"));
|
RbacSubjectEntity.builder().name("test-user-" + System.currentTimeMillis() + "@example.com").build());
|
||||||
}
|
}
|
||||||
|
|
||||||
void exactlyTheseRbacGrantsAreReturned(final List<RbacGrantEntity> actualResult, final String... expectedGrant) {
|
void exactlyTheseRbacGrantsAreReturned(final List<RbacGrantEntity> actualResult, final String... expectedGrant) {
|
||||||
|
|||||||
+3
-1
@@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.UUID.randomUUID;
|
||||||
import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer;
|
import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.hamcrest.Matchers.allOf;
|
import static org.hamcrest.Matchers.allOf;
|
||||||
@@ -454,7 +455,8 @@ class RbacSubjectControllerAcceptanceTest {
|
|||||||
final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com";
|
final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com";
|
||||||
final var givenUser = jpaAttempt.transacted(() -> {
|
final var givenUser = jpaAttempt.transacted(() -> {
|
||||||
context.define("superuser-alex@hostsharing.net");
|
context.define("superuser-alex@hostsharing.net");
|
||||||
return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), givenUserName));
|
return rbacSubjectRepository.create(
|
||||||
|
RbacSubjectEntity.builder().uuid(randomUUID()).name(givenUserName).build());
|
||||||
}).assumeSuccessful().returnedValue();
|
}).assumeSuccessful().returnedValue();
|
||||||
assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull();
|
assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull();
|
||||||
return givenUser;
|
return givenUser;
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
|
import static java.util.UUID.randomUUID;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class RbacSubjectEntityUnitTest {
|
class RbacSubjectEntityUnitTest {
|
||||||
|
|
||||||
RbacSubjectEntity givenUser = new RbacSubjectEntity(UUID.randomUUID(), "test@example.org");
|
RbacSubjectEntity givenUser = RbacSubjectEntity.builder().uuid(randomUUID()).name("test@example.org").build();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generatedAccessCodeMatchesDefinedPattern() {
|
void generatedAccessCodeMatchesDefinedPattern() {
|
||||||
|
|||||||
+3
-2
@@ -56,7 +56,7 @@ class RbacSubjectRepositoryIntegrationTest extends ContextBasedTest {
|
|||||||
// when:
|
// when:
|
||||||
final var result = jpaAttempt.transacted(() -> {
|
final var result = jpaAttempt.transacted(() -> {
|
||||||
context(null);
|
context(null);
|
||||||
return rbacSubjectRepository.create(new RbacSubjectEntity(givenUuid, newUserName));
|
return rbacSubjectRepository.create(RbacSubjectEntity.builder().uuid(givenUuid).name(newUserName).build());
|
||||||
});
|
});
|
||||||
|
|
||||||
// then:
|
// then:
|
||||||
@@ -395,7 +395,8 @@ class RbacSubjectRepositoryIntegrationTest extends ContextBasedTest {
|
|||||||
final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com";
|
final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com";
|
||||||
final var givenUser = jpaAttempt.transacted(() -> {
|
final var givenUser = jpaAttempt.transacted(() -> {
|
||||||
context(null);
|
context(null);
|
||||||
return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), givenUserName));
|
return rbacSubjectRepository.create(
|
||||||
|
RbacSubjectEntity.builder().uuid(UUID.randomUUID()).name(givenUserName).build());
|
||||||
}).assumeSuccessful().returnedValue();
|
}).assumeSuccessful().returnedValue();
|
||||||
assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull();
|
assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull();
|
||||||
return givenUser;
|
return givenUser;
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ public class TestRbacSubject {
|
|||||||
static final RbacSubjectEntity userBbb = rbacRole("customer-admin@bbb.example.com");
|
static final RbacSubjectEntity userBbb = rbacRole("customer-admin@bbb.example.com");
|
||||||
|
|
||||||
static public RbacSubjectEntity rbacRole(final String userName) {
|
static public RbacSubjectEntity rbacRole(final String userName) {
|
||||||
return new RbacSubjectEntity(randomUUID(), userName);
|
return RbacSubjectEntity.builder().uuid(randomUUID()).name(userName).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user