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,