1
0

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:
Michael Hoennig
2025-09-17 12:14:47 +02:00
parent bae13d5503
commit 4994341232
32 changed files with 786 additions and 128 deletions
@@ -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));
}
@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 })
protected ResponseEntity<CustomErrorResponse> handleJpaObjectRetrievalFailureException(
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.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<HashGenerator, String, String> 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);
@@ -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;
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<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())
)
);
entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes()));
entity.setPerson(person);
entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes()));
entity.setPassword(resource.getPassword());
};
}
@@ -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<HsProfileEntity>, 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<HsProfileEntity>, Stringifyab
@Column
private Integer globalGid;
@Column(name = "password_hash")
private String passwordHash;
@Column
private List<String> totpSecrets;
@@ -86,11 +93,24 @@ public class HsProfileEntity implements BaseEntity<HsProfileEntity>, 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<HsProfileEntity>, 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");
}
}
}
@@ -18,20 +18,20 @@ public class HsProfileEntityPatcher implements EntityPatcher<ProfilePatchResourc
@Override
public void apply(final ProfilePatchResource resource) {
if ( resource.getActive() != null ) {
entity.setActive(resource.getActive());
}
Optional.ofNullable(resource.getActive())
.ifPresent(entity::setActive);
OptionalFromJson.of(resource.getEmailAddress())
.ifPresent(entity::setEmailAddress);
Optional.ofNullable(resource.getTotpSecrets())
.ifPresent(entity::setTotpSecrets);
OptionalFromJson.of(resource.getSmsNumber())
.ifPresent(entity::setSmsNumber);
Optional.ofNullable(resource.getPassword())
.ifPresent(entity::setPassword);
OptionalFromJson.of(resource.getPhonePassword())
.ifPresent(entity::setPhonePassword);
if (resource.getScopes() != null) {
scopeMapper.syncProfileScopeEntities(resource.getScopes(), entity.getScopes());
}
}
}
@@ -1,59 +1,26 @@
package net.hostsharing.hsadminng.rbac.subject;
import lombok.*;
import net.hostsharing.hsadminng.persistence.ImmutableBaseEntity;
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.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@Entity
@Table(schema = "rbac", name = "subject_rv")
@Getter
@Setter
@Builder
@ToString
@SuperBuilder(toBuilder = true)
@ToString(callSuper = true)
@Immutable
@NoArgsConstructor
@AllArgsConstructor
public class RbacSubjectEntity implements ImmutableBaseEntity<RbacSubjectEntity> {
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<RbacSubjectEntity> {
}
@@ -15,19 +15,19 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
where :userName is null or u.name like concat(cast(:userName as text), '%')
order by u.name
""")
@Timed("app.rbac.subjects.repo.findByOptionalNameLike")
@Timed("app.rbac.subjects.repo.findByOptionalNameLike.rbac")
List<RbacSubjectEntity> 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<RbacSubjectPermission> findPermissionsOfUserByUuid(UUID subjectUuid);
/*
@@ -37,7 +37,7 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
*/
@Modifying
@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);
default RbacSubjectEntity create(final RbacSubjectEntity rbacSubjectEntity) {
@@ -52,6 +52,6 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
return rbacSubjectEntity; // Not yet attached to EM!
}
@Timed("app.rbac.subjects.repo.deleteByUuid")
@Timed("app.rbac.subjects.repo.deleteByUuid.rbac")
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:
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:
@@ -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,
@@ -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;
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
@@ -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()));
@@ -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");
}
}
@@ -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())
@@ -35,13 +35,14 @@ public class CreateProfile extends BaseProfileUseCase<CreateProfile> {
{
"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}
}
"""))
@@ -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(
@@ -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();
}
@@ -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<RbacGrantEntity> actualResult, final String... expectedGrant) {
@@ -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;
@@ -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() {
@@ -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;
@@ -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();
}
}