diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index d1e19cb657a..71e04f00599 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -37,6 +37,7 @@ public final class Const { public static final Duration FEEDBACK_SESSION_EVENT_EMAIL_LOOKBACK_WINDOW = Duration.ofDays(2); public static final Duration LOGS_RETENTION_PERIOD = Duration.ofDays(30); public static final Duration COOKIE_VALIDITY_PERIOD = Duration.ofDays(7); + public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofMinutes(5); public static final int SEARCH_QUERY_SIZE_LIMIT = 50; diff --git a/src/main/java/teammates/storage/entity/MagicLink.java b/src/main/java/teammates/storage/entity/MagicLink.java new file mode 100644 index 00000000000..9e335d3ac92 --- /dev/null +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -0,0 +1,125 @@ +package teammates.storage.entity; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import teammates.common.util.Const; +import teammates.common.util.FieldValidator; +import teammates.common.util.SanitizationHelper; + +/** + * Represents a passwordless login magic link. + */ +@Entity +@Table(name = "MagicLinks") +public class MagicLink extends BaseEntity { + + @Id + private UUID id; + + @Column(nullable = false) + private String email; + + @Column(nullable = false, unique = true) + private String tokenHash; + + @Column(nullable = false) + private Instant expiresAt; + + protected MagicLink() { + // required by Hibernate + } + + public MagicLink(String email, String tokenHash, Instant now) { + this.setId(UUID.randomUUID()); + this.setEmail(email); + this.setTokenHash(tokenHash); + this.setExpiresAt(now.plus(Const.MAGIC_LINK_VALIDITY_PERIOD)); + } + + @Override + public List getInvalidityInfo() { + List errors = new ArrayList<>(); + + addNonEmptyError(FieldValidator.getInvalidityInfoForEmail(email), errors); + + return errors; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = SanitizationHelper.sanitizeEmail(email); + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = SanitizationHelper.sanitizeTextField(tokenHash); + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + /** + * Returns true if the magic link expires at or before {@code now}. + */ + public boolean isExpired(Instant now) { + return !expiresAt.isAfter(now); + } + + /** + * Returns true if the magic link can still be used. + */ + public boolean isUsable(Instant now) { + return !isExpired(now); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof MagicLink other)) { + return false; + } + + return getId() != null && getId().equals(other.getId()); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public String toString() { + return "MagicLink [id=" + id + ", email=" + email + ", expiresAt=" + expiresAt + + ", createdAt=" + getCreatedAt() + "]"; + } +} diff --git a/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml b/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml new file mode 100644 index 00000000000..d7fe03f5d43 --- /dev/null +++ b/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + +