package com.example.security; import jakarta.persistence.*; import org.jspecify.annotations.Nullable; import java.util.HashSet; import java.util.Set; @Entity @Table(name = "app_user") public class User { public static final int USERNAME_MAX_LENGTH = 100; @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) @Column(name = "user_id") private Long id; @Column(name = "username", nullable = false, unique = true, length = USERNAME_MAX_LENGTH) private String username = ""; @Column(name = "password_hash", nullable = false) private String passwordHash = ""; @Column(name = "enabled", nullable = false) private boolean enabled = true; @Column(name = "totp_secret") private @Nullable String totpSecret; @Column(name = "two_factor_enabled", nullable = false) private boolean twoFactorEnabled = false; // The most recently accepted TOTP time step, tracked to reject replay of an // observed code within its own validity window (see TwoFactorService#verify). @Column(name = "totp_last_used_step") private @Nullable Long totpLastUsedTimeStep; @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "app_user_role", joinColumns = @JoinColumn(name = "user_id")) @Column(name = "role", nullable = false) @Enumerated(EnumType.STRING) private Set roles = new HashSet<>(); protected User() { // To keep Hibernate happy } public User(String username, String passwordHash) { setUsername(username); setPasswordHash(passwordHash); } public @Nullable Long getId() { return id; } public String getUsername() { return username; } public void setUsername(String username) { if (username.isBlank()) { throw new IllegalArgumentException("Username must not be blank"); } if (username.length() > USERNAME_MAX_LENGTH) { throw new IllegalArgumentException("Username length exceeds " + USERNAME_MAX_LENGTH); } this.username = username; } public String getPasswordHash() { return passwordHash; } public void setPasswordHash(String passwordHash) { if (passwordHash.isBlank()) { throw new IllegalArgumentException("Password hash must not be blank"); } this.passwordHash = passwordHash; } public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public Set getRoles() { return roles; } public void addRole(Role role) { roles.add(role); } public @Nullable String getTotpSecret() { return totpSecret; } public boolean isTwoFactorEnabled() { return twoFactorEnabled; } public @Nullable Long getTotpLastUsedTimeStep() { return totpLastUsedTimeStep; } /** * Enables two-factor authentication with the given TOTP secret. The caller * must have already verified a code against this secret before calling * this method — it does not verify anything itself. */ public void enableTwoFactor(String totpSecret) { if (totpSecret.isBlank()) { throw new IllegalArgumentException("TOTP secret must not be blank"); } this.totpSecret = totpSecret; this.twoFactorEnabled = true; this.totpLastUsedTimeStep = null; } public void recordAcceptedTotpTimeStep(long timeStep) { this.totpLastUsedTimeStep = timeStep; } @Override public boolean equals(Object obj) { if (obj == null || !getClass().isAssignableFrom(obj.getClass())) { return false; } if (obj == this) { return true; } User other = (User) obj; return getId() != null && getId().equals(other.getId()); } @Override public int hashCode() { // Hashcode should never change during the lifetime of an object. Because of // this we can't use getId() to calculate the hashcode. Unless you have sets // with lots of entities in them, returning the same hashcode should not be a // problem. return getClass().hashCode(); } }