diff --git a/.tc-environment b/.tc-environment index 063792cd..9eec38d4 100644 --- a/.tc-environment +++ b/.tc-environment @@ -4,6 +4,7 @@ export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net 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_CLIENT_ID=hsscript.ng export HSADMINNG_JWT_CLIENT_SECRET= diff --git a/.unset-environment b/.unset-environment index aec5ceb3..9020da03 100644 --- a/.unset-environment +++ b/.unset-environment @@ -6,6 +6,8 @@ unset HSADMINNG_SUPERUSER unset HSADMINNG_MIGRATION_DATA_PATH unset HSADMINNG_OFFICE_DATA_SQL_FILE +unset HSADMINNG_ACCOUNT_PASSWORD_HASH_ALGORITHM + unset HSADMINNG_JWT_ISSUER unset HSADMINNG_JWT_JWKS_URL unset HSADMINNG_JWT_TOKEN_URL diff --git a/build.gradle.kts b/build.gradle.kts index 22c1b07a..e7a9260f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { implementation("org.modelmapper:modelmapper:3.2.4") implementation("org.iban4j:iban4j:3.2.11-RELEASE") implementation("org.reflections:reflections:0.10.2") + implementation("de.mkammerer:argon2-jvm:2.11") compileOnly("org.projectlombok:lombok") testCompileOnly("org.projectlombok:lombok") diff --git a/etc/allowed-licenses.json b/etc/allowed-licenses.json index 61ca10e8..0ead0cfc 100644 --- a/etc/allowed-licenses.json +++ b/etc/allowed-licenses.json @@ -54,6 +54,7 @@ { "moduleLicense": "GNU Library General Public License v2.1 or later" }, { "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 3" }, { "moduleLicense": "GPL2 w/ CPE" }, diff --git a/src/main/java/net/hostsharing/hsadminng/errors/ForbiddenException.java b/src/main/java/net/hostsharing/hsadminng/errors/ForbiddenException.java new file mode 100644 index 00000000..a0359c11 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/errors/ForbiddenException.java @@ -0,0 +1,8 @@ +package net.hostsharing.hsadminng.errors; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(final String message) { + super(message); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java index 92822364..f3b4f57a 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java @@ -84,6 +84,12 @@ public class RestResponseEntityExceptionHandler return errorResponse(request, HttpStatus.BAD_REQUEST, localizedMessage(exc)); } + @ExceptionHandler(ForbiddenException.class) + protected ResponseEntity handleForbiddenException( + final ForbiddenException exc, final WebRequest request) { + return errorResponse(request, HttpStatus.FORBIDDEN, localizedMessage(exc)); + } + @ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class }) protected ResponseEntity handleJpaObjectRetrievalFailureException( final RuntimeException exc, final WebRequest request) { diff --git a/src/main/java/net/hostsharing/hsadminng/hash/Base64Utils.java b/src/main/java/net/hostsharing/hsadminng/hash/Base64Utils.java new file mode 100644 index 00000000..761c5a5c --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/Base64Utils.java @@ -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+/]+$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java b/src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java index d367918e..2b18b661 100644 --- a/src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java +++ b/src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java @@ -2,12 +2,14 @@ package net.hostsharing.hsadminng.hash; import java.security.SecureRandom; import java.util.Arrays; +import java.util.Optional; import java.util.PriorityQueue; import java.util.Queue; import java.util.function.BiFunction; import java.util.random.RandomGenerator; import lombok.Getter; +import lombok.val; /** * Usage-example to generate hash: @@ -29,6 +31,15 @@ public final class HashGenerator { "0123456789/."; 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 { LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"), LINUX_YESCRYPT(LinuxEtcShadowHashGenerator::hash, "y", "j9T$") { @@ -38,7 +49,14 @@ public final class HashGenerator { } }, 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 implementation; final String prefix; @@ -85,7 +103,7 @@ public final class HashGenerator { public String hash(final String plaintextPassword) { if (plaintextPassword == null) { - throw new IllegalStateException("no password given"); + return null; } final var hash = algorithm.implementation.apply(this, plaintextPassword); diff --git a/src/main/java/net/hostsharing/hsadminng/hash/LdapArgon2Hash.java b/src/main/java/net/hostsharing/hsadminng/hash/LdapArgon2Hash.java new file mode 100644 index 00000000..82fdbfac --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/LdapArgon2Hash.java @@ -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=..$$ + final var phc = argon2.hash(iterations, memoryKiB, parallelism, password.toCharArray()); + // but LDAP expects {ARGON2} + 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}"); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hash/LdapSshaHash.java b/src/main/java/net/hostsharing/hsadminng/hash/LdapSshaHash.java new file mode 100644 index 00000000..574d40bb --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/LdapSshaHash.java @@ -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())); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java index 8d61c84c..c17de533 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileController.java @@ -1,30 +1,27 @@ 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.swagger.v3.oas.annotations.security.SecurityRequirement; 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.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.ProfilePatchResource; 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.HsOfficePersonRealRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; import net.hostsharing.hsadminng.mapper.StrictMapper; 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.RbacSubjectRepository; +import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; 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.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; @@ -51,9 +52,6 @@ public class HsProfileController implements ProfileApi { @Autowired private StrictMapper mapper; - @Autowired - private RbacSubjectRepository subjectRepo; - @Autowired private ScopeResourceToEntityMapper scopeMapper; @@ -159,6 +157,7 @@ public class HsProfileController implements ProfileApi { val current = profileRepo.findByUuid(profileUuid).orElseThrow(); + validateBeforePatch(current, body); new HsProfileEntityPatcher(scopeMapper, current).apply(body); validateOnUpdate(current); @@ -188,6 +187,11 @@ public class HsProfileController implements ProfileApi { 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) { validateReferencedPersonToBeRepresentedByLoginUserPerson(newProfileEntity); validateNormalUsersOnlyAccessPublicScopes(newProfileEntity); @@ -276,10 +280,10 @@ public class HsProfileController implements ProfileApi { .collect(Collectors.joining(", ")); } - private RbacSubjectEntity createSubject(final String nickname) { - val rbacSubjectEntity = new RbacSubjectEntity(null, nickname); - val newRbacSubject = subjectRepo.create(rbacSubjectEntity); - return newRbacSubject; + private RealSubjectEntity createSubject(final String nickname) { + val rbacSubjectEntity = RbacSubjectEntity.builder().name(nickname).build(); + val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity); + return em.find(RealSubjectEntity.class, newRbacSubject.getUuid()); } private List 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()) ) ); - - entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes())); - entity.setPerson(person); + entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes())); + entity.setPassword(resource.getPassword()); }; } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java index 91849565..d3b8bcaf 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntity.java @@ -1,13 +1,17 @@ package net.hostsharing.hsadminng.hs.accounts; import jakarta.persistence.*; +import jakarta.validation.ValidationException; + 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.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.Stringifyable; -// import net.hostsharing.hsadminng.rbac.RbacSubjectEntity; // Assuming RbacSubjectEntity exists for the FK relationship import java.util.HashSet; import java.util.List; @@ -41,7 +45,7 @@ public class HsProfileEntity implements BaseEntity, Stringifyab @MapsId @OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid") - private RbacSubjectEntity subject; + private RealSubjectEntity subject; @ManyToOne(optional = false, fetch = FetchType.EAGER) @JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid") @@ -59,6 +63,9 @@ public class HsProfileEntity implements BaseEntity, Stringifyab @Column private Integer globalGid; + @Column(name = "password_hash") + private String passwordHash; + @Column private List totpSecrets; @@ -86,11 +93,24 @@ public class HsProfileEntity implements BaseEntity, Stringifyab return scopes; } - public void setSubject(final RbacSubjectEntity subject) { + public void setSubject(final RealSubjectEntity subject) { this.uuid = subject.getUuid(); 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 public String toShortString() { return active + ":" + emailAddress + ":" + globalUid; @@ -101,4 +121,10 @@ public class HsProfileEntity implements BaseEntity, Stringifyab 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"); + } + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java index c2d05192..083d7605 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityPatcher.java @@ -18,20 +18,20 @@ public class HsProfileEntityPatcher implements EntityPatcher { - - private static final int MAX_VALIDITY_DAYS = 21; - 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); - } +@AttributeOverrides({ + @AttributeOverride(name = "uuid", column = @Column(name = "uuid")) +}) +public class RbacSubjectEntity extends Subject { } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java index 789536c5..02d6af77 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepository.java @@ -15,19 +15,19 @@ public interface RbacSubjectRepository extends Repository findByOptionalNameLike(String userName); // bypasses the restricted view, to be able to grant rights to arbitrary user @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); - @Timed("app.rbac.subjects.repo.findByUuid") + @Timed("app.rbac.subjects.repo.findByUuid.rbac") RbacSubjectEntity findByUuid(UUID uuid); @Query(value = "select * from rbac.grantedPermissions(:subjectUuid)", nativeQuery = true) - @Timed("app.rbac.subjects.repo.findPermissionsOfUserByUuid") + @Timed("app.rbac.subjects.repo.findPermissionsOfUserByUuid.rbac") List findPermissionsOfUserByUuid(UUID subjectUuid); /* @@ -37,7 +37,7 @@ public interface RbacSubjectRepository extends Repository { + +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/Subject.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/Subject.java new file mode 100644 index 00000000..e26c82a6 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/Subject.java @@ -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 & ImmutableBaseEntity> implements ImmutableBaseEntity { + + 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); + } +} diff --git a/src/main/resources/api-definition/accounts/profile-schemas.yaml b/src/main/resources/api-definition/accounts/profile-schemas.yaml index c263cc1d..4da9ec84 100644 --- a/src/main/resources/api-definition/accounts/profile-schemas.yaml +++ b/src/main/resources/api-definition/accounts/profile-schemas.yaml @@ -24,16 +24,16 @@ components: nickname: type: string 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: type: array items: type: string phonePassword: type: string - emailAddress: - type: string - smsNumber: - type: string active: type: boolean globalUid: @@ -53,19 +53,23 @@ components: ProfilePatch: type: object properties: - totpSecrets: - type: array - items: - type: string - phonePassword: - type: string - nullable: true emailAddress: type: string nullable: true smsNumber: type: string 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: type: boolean scopes: @@ -93,6 +97,10 @@ components: type: number globalGid: type: number + password: + type: string + minLength: 8 + description: plaintext password or valid hash phonePassword: type: string totpSecrets: diff --git a/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql b/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql index 122b4105..9b246c1a 100644 --- a/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql +++ b/src/main/resources/db/changelog/9-hs-global/950-accounts/9510-hs-accounts.sql @@ -16,6 +16,7 @@ create table hs_accounts.profile global_uid int unique, -- w/o global_gid int unique, -- w/o + password_hash text, totp_secrets text[], phone_password text, email_address text, diff --git a/src/test/java/net/hostsharing/hsadminng/hash/Base64UtilsUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hash/Base64UtilsUnitTest.java new file mode 100644 index 00000000..95be620b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hash/Base64UtilsUnitTest.java @@ -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(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hash/HashGeneratorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hash/HashGeneratorUnitTest.java index c43550b9..49f35ea2 100644 --- a/src/test/java/net/hostsharing/hsadminng/hash/HashGeneratorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hash/HashGeneratorUnitTest.java @@ -1,10 +1,15 @@ package net.hostsharing.hsadminng.hash; +import lombok.val; 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.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_YESCRYPT; 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.catchThrowable; +@ExtendWith(MockitoExtension.class) class HashGeneratorUnitTest { final String GIVEN_PASSWORD = "given password"; @@ -27,6 +33,89 @@ class HashGeneratorUnitTest { // 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="; + @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 void verifiesLinuxPasswordAgainstSha512HashFromMkpasswd() { LinuxEtcShadowHashGenerator.verify(GIVEN_LINUX_GENERATED_SHA512_HASH, GIVEN_PASSWORD); // throws exception if wrong diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java index 2195161e..25dae980 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileControllerAcceptanceTest.java @@ -9,6 +9,7 @@ import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; 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.JpaAttempt; import org.jetbrains.annotations.NotNull; @@ -52,7 +53,7 @@ class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup { Context context; @Autowired - RbacSubjectRepository subjectRepo; + RbacSubjectRepository rbacSubjectRepo; @Autowired HsOfficePersonRealRepository realPersonRepo; @@ -361,9 +362,59 @@ class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup { .body("message", containsString("die eigenen hsadmin-Profile dürfen nicht entfernt werden")); // @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 + + 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) { return jpaAttempt.transacted(() -> { context.define(executingSubjectName); @@ -418,16 +469,17 @@ class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup { ) { return jpaAttempt.transacted(() -> { context.define(executingSubjectName); - final RbacSubjectEntity rbacSubjectEntity = RbacSubjectEntity.builder() + + // only RbacSubject entities can be created + val subject = rbacSubjectRepo.create(RbacSubjectEntity.builder() .name(newSubjectName) - .build(); - val subject = subjectRepo.create(rbacSubjectEntity); + .build()); context.define(subject.getName()); val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid()); val profileBuilder = HsProfileEntity.builder() .person(attachedPerson) - .subject(subjectRepo.findByUuid(subject.getUuid())) + .subject(em.find(RealSubjectEntity.class, subject.getUuid())) .scopes(Set.of()); modifier.accept(profileBuilder); return toCleanup(profileRepo.save(profileBuilder.build())); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityUnitTest.java new file mode 100644 index 00000000..7732b3a2 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileEntityUnitTest.java @@ -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"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepositoryIntegrationTest.java index c9bac4c0..4aae66a6 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsProfileRepositoryIntegrationTest.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.hs.accounts; +import lombok.val; import net.hostsharing.hsadminng.rbac.context.Context; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; 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.HsOfficeRelationType; 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.JpaAttempt; import org.hibernate.TransientObjectException; @@ -48,6 +51,9 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @MockitoBean HttpServletRequest request; + @Autowired + private RbacSubjectRepository rbacSubjectRepo; + @Autowired private HsOfficePersonRealRepository personRepo; @@ -58,9 +64,9 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { private HsProfileScopeRealRepository scopeRealRepo; // fetched UUIDs from test-data - private RbacSubjectEntity alexSubject; - private RbacSubjectEntity drewSubject; - private RbacSubjectEntity testUserSubject; + private RealSubjectEntity alexSubject; + private RealSubjectEntity drewSubject; + private RealSubjectEntity testUserSubject; private HsOfficePersonRealEntity drewPerson; private HsOfficePersonRealEntity testUserPerson; @@ -106,11 +112,13 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { givenRelation(REPRESENTATIVE) .withAnchorPersonLike(firstGmbHPerson) .withHolder(drewPerson) - .withContact("some test contact"); + .withContact("some test contact") + .inDatabase(); givenProfile() .forSubject("first-gmbh") .forPerson(firstGmbHPerson) - .withEMailAddress("first-gmbh@example.com"); + .withEMailAddress("first-gmbh@example.com") + .inDatabase(); // when final var foundProfile = attempt( @@ -263,13 +271,13 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { } - private RbacSubjectEntity fetchSubjectByName(final String name) { - final String jpql = "SELECT s FROM RbacSubjectEntity s WHERE s.name = :name"; - final Query query = em.createQuery(jpql, RbacSubjectEntity.class); + private RealSubjectEntity fetchSubjectByName(final String name) { + final String jpql = "SELECT s FROM RealSubjectEntity s WHERE s.name = :name"; + final Query query = em.createQuery(jpql, RealSubjectEntity.class); query.setParameter("name", name); try { context(SUPERUSER_ALEX_SUBJECT_NAME); - return notNull((RbacSubjectEntity) query.getSingleResult()); + return notNull((RealSubjectEntity) query.getSingleResult()); } catch (final NoResultException e) { throw new AssertionError( "Failed to find subject with name '" + name + "'. Ensure test data is present.", e); @@ -331,10 +339,14 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { return this; } - public HsOfficeRelationRealEntity withContact(String caption) { + public RelationBuilder withContact(String caption) { this.contact = HsOfficeContactRealEntity.builder() .caption(caption) .build(); + return this; + } + + public HsOfficeRelationRealEntity inDatabase() { em.persist(contact); final var relation = HsOfficeRelationRealEntity.builder() @@ -350,15 +362,17 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { } private class ProfileBuilder { - private RbacSubjectEntity subject; + private RealSubjectEntity subject; private HsOfficePersonRealEntity person; + private String emailAddress; public ProfileBuilder forSubject(String subjectName) { - this.subject = RbacSubjectEntity.builder() + // only the RbacSubject can be created + val rbacSubject = toCleanup(rbacSubjectRepo.create(RbacSubjectEntity.builder() .name(subjectName) - .build(); - em.persist(subject); - toCleanup(subject); + .build())); + // but we need the RealSubject + this.subject = em.find(RealSubjectEntity.class, rbacSubject.getUuid()); return this; } @@ -367,7 +381,19 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { 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() .uuid(subject.getUuid()) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfile.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfile.java index 5e326014..13799dd4 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfile.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CreateProfile.java @@ -35,13 +35,14 @@ public class CreateProfile extends BaseProfileUseCase { { "person.uuid": ${Person: %{personGivenName} %{personFamilyName}}, "nickname": ${nickname}, - "active": %{active}, - "totpSecrets": @{totpSecrets}, "emailAddress": ${emailAddress}, - "phonePassword": ${phonePassword}, "smsNumber": ${smsNumber}, + "password": ${password}, + "totpSecrets": @{totpSecrets}, + "phonePassword": ${phonePassword}, "globalUid": %{globalUid}, "globalGid": %{globalGid}, + "active": %{active}, "scopes": @{resolvedScopes} } """)) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java index 673508fd..5276b419 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/ProfileScenarioTests.java @@ -74,13 +74,14 @@ class ProfileScenarioTests extends ScenarioTest { // a login name, to be stored in the new RBAC subject .given("nickname", "firby-susan") // initial profile - .given("active", true) - .given("totpSecrets", Array.of("initialSecret")) .given("emailAddress", "susan.firby@example.com") - .given("phonePassword", "securePass123") .given("smsNumber", "+49123456789") + .given("password", "my raw password") + .given("totpSecrets", Array.of("initialSecret")) + .given("phonePassword", "securePass123") .given("globalUid", 21011) .given("globalGid", 21011) + .given("active", true) .given( "scopes", Array.of( Pair.of("HSADMIN", "prod") @@ -100,6 +101,7 @@ class ProfileScenarioTests extends ScenarioTest { .given("active", false) .given("totpSecrets", Array.of("initialSecret", "additionalSecret")) .given("emailAddress", "susan.firby@example.org") + .given("password", "my new raw password") .given("phonePassword", "securePass987") .given("smsNumber", "+49987654321") .given( diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantControllerAcceptanceTest.java index 313d931c..a4e09b95 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantControllerAcceptanceTest.java @@ -24,8 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.util.List; -import java.util.UUID; +import static java.util.UUID.randomUUID; import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.containsString; @@ -491,7 +491,8 @@ class RbacGrantControllerAcceptanceTest extends ContextBasedTest { return jpaAttempt.transacted(() -> { final String newUserName = "test-user-" + RandomStringUtils.randomAlphabetic(8) + "@example.com"; context(null); - return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), newUserName)); + return rbacSubjectRepository.create( + RbacSubjectEntity.builder().uuid(randomUUID()).name(newUserName).build()); }).returnedValue(); } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantRepositoryIntegrationTest.java index daf06e93..94b351ca 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantRepositoryIntegrationTest.java @@ -322,13 +322,13 @@ class RbacGrantRepositoryIntegrationTest extends ContextBasedTest { return jpaAttempt.transacted(() -> { final var newUserName = "test-user-" + System.currentTimeMillis() + "@example.com"; context(null); - return rbacSubjectRepository.create(new RbacSubjectEntity(null, newUserName)); + return rbacSubjectRepository.create(RbacSubjectEntity.builder().name(newUserName).build()); }).assumeSuccessful().returnedValue(); } private RbacSubjectEntity createNewUser() { 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 actualResult, final String... expectedGrant) { diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java index dfe51b24..fbb59ed1 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectControllerAcceptanceTest.java @@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.UUID; +import static java.util.UUID.randomUUID; import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.allOf; @@ -454,7 +455,8 @@ class RbacSubjectControllerAcceptanceTest { final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com"; final var givenUser = jpaAttempt.transacted(() -> { 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(); assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull(); return givenUser; diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntityUnitTest.java index 44dde522..44723098 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntityUnitTest.java @@ -4,14 +4,14 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; 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.assertThatThrownBy; class RbacSubjectEntityUnitTest { - RbacSubjectEntity givenUser = new RbacSubjectEntity(UUID.randomUUID(), "test@example.org"); + RbacSubjectEntity givenUser = RbacSubjectEntity.builder().uuid(randomUUID()).name("test@example.org").build(); @Test void generatedAccessCodeMatchesDefinedPattern() { diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepositoryIntegrationTest.java index 2bd2c2fa..ab61e39c 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectRepositoryIntegrationTest.java @@ -56,7 +56,7 @@ class RbacSubjectRepositoryIntegrationTest extends ContextBasedTest { // when: final var result = jpaAttempt.transacted(() -> { context(null); - return rbacSubjectRepository.create(new RbacSubjectEntity(givenUuid, newUserName)); + return rbacSubjectRepository.create(RbacSubjectEntity.builder().uuid(givenUuid).name(newUserName).build()); }); // then: @@ -395,7 +395,8 @@ class RbacSubjectRepositoryIntegrationTest extends ContextBasedTest { final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com"; final var givenUser = jpaAttempt.transacted(() -> { context(null); - return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), givenUserName)); + return rbacSubjectRepository.create( + RbacSubjectEntity.builder().uuid(UUID.randomUUID()).name(givenUserName).build()); }).assumeSuccessful().returnedValue(); assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull(); return givenUser; diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/subject/TestRbacSubject.java b/src/test/java/net/hostsharing/hsadminng/rbac/subject/TestRbacSubject.java index 05388f0c..51b10f8b 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/subject/TestRbacSubject.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/subject/TestRbacSubject.java @@ -9,6 +9,6 @@ public class TestRbacSubject { static final RbacSubjectEntity userBbb = rbacRole("customer-admin@bbb.example.com"); static public RbacSubjectEntity rbacRole(final String userName) { - return new RbacSubjectEntity(randomUUID(), userName); + return RbacSubjectEntity.builder().uuid(randomUUID()).name(userName).build(); } }