From 16eef3588238c46bb69ab36a7b9fd922ef9f47af Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Tue, 14 Jul 2026 15:55:05 +0800 Subject: [PATCH 1/7] Create magic link entity --- .../java/teammates/common/util/Const.java | 1 + .../teammates/storage/entity/MagicLink.java | 188 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/main/java/teammates/storage/entity/MagicLink.java diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 705bf51338c..e4b0fe9694e 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(15); 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..9bbe4afed16 --- /dev/null +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -0,0 +1,188 @@ +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 org.hibernate.annotations.NaturalId; +import org.hibernate.annotations.UpdateTimestamp; + +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; + + @NaturalId + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false, unique = true) + private String tokenHash; + + @Column(nullable = false) + private Instant expiresAt; + + private Instant usedAt; + + @Column(nullable = false) + private boolean revoked; + + @UpdateTimestamp + private Instant updatedAt; + + 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)); + this.setUsedAt(null); + this.setRevoked(false); + } + + @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; + } + + public Instant getUsedAt() { + return usedAt; + } + + public void setUsedAt(Instant usedAt) { + this.usedAt = usedAt; + } + + public boolean isRevoked() { + return revoked; + } + + public void setRevoked(boolean revoked) { + this.revoked = revoked; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + /** + * 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 has already been used. + */ + public boolean isUsed() { + return usedAt != null; + } + + /** + * Returns true if the magic link can still be used. + */ + public boolean isUsable(Instant now) { + return !isUsed() && !isRevoked() && !isExpired(now); + } + + /** + * Marks the magic link as used if it has not already been used. + */ + public void markUsed(Instant now) { + if (isUsed()) { + return; + } + this.usedAt = now; + } + + /** + * Revokes the magic link. + */ + public void revoke() { + this.revoked = true; + } + + @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 + + ", usedAt=" + usedAt + ", revoked=" + revoked + + ", createdAt=" + getCreatedAt() + ", updatedAt=" + updatedAt + "]"; + } +} From 1a09a723a4ed12828cbdbd2f5c089d3afc65467a Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Tue, 14 Jul 2026 16:29:50 +0800 Subject: [PATCH 2/7] Change validity period --- src/main/java/teammates/common/util/Const.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index e4b0fe9694e..6ecef483600 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -37,7 +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(15); + public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofHours(1); public static final int SEARCH_QUERY_SIZE_LIMIT = 50; From bd2343a0fa9f350786dec240b1b608316b83a0e8 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Tue, 14 Jul 2026 16:39:01 +0800 Subject: [PATCH 3/7] Remove fields --- .../teammates/storage/entity/MagicLink.java | 50 +------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/src/main/java/teammates/storage/entity/MagicLink.java b/src/main/java/teammates/storage/entity/MagicLink.java index 9bbe4afed16..ae77ded7ee9 100644 --- a/src/main/java/teammates/storage/entity/MagicLink.java +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -37,11 +37,6 @@ public class MagicLink extends BaseEntity { @Column(nullable = false) private Instant expiresAt; - private Instant usedAt; - - @Column(nullable = false) - private boolean revoked; - @UpdateTimestamp private Instant updatedAt; @@ -54,8 +49,6 @@ public MagicLink(String email, String tokenHash, Instant now) { this.setEmail(email); this.setTokenHash(tokenHash); this.setExpiresAt(now.plus(Const.MAGIC_LINK_VALIDITY_PERIOD)); - this.setUsedAt(null); - this.setRevoked(false); } @Override @@ -99,22 +92,6 @@ public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } - public Instant getUsedAt() { - return usedAt; - } - - public void setUsedAt(Instant usedAt) { - this.usedAt = usedAt; - } - - public boolean isRevoked() { - return revoked; - } - - public void setRevoked(boolean revoked) { - this.revoked = revoked; - } - public Instant getUpdatedAt() { return updatedAt; } @@ -130,35 +107,11 @@ public boolean isExpired(Instant now) { return !expiresAt.isAfter(now); } - /** - * Returns true if the magic link has already been used. - */ - public boolean isUsed() { - return usedAt != null; - } - /** * Returns true if the magic link can still be used. */ public boolean isUsable(Instant now) { - return !isUsed() && !isRevoked() && !isExpired(now); - } - - /** - * Marks the magic link as used if it has not already been used. - */ - public void markUsed(Instant now) { - if (isUsed()) { - return; - } - this.usedAt = now; - } - - /** - * Revokes the magic link. - */ - public void revoke() { - this.revoked = true; + return !isExpired(now); } @Override @@ -182,7 +135,6 @@ public int hashCode() { @Override public String toString() { return "MagicLink [id=" + id + ", email=" + email + ", expiresAt=" + expiresAt - + ", usedAt=" + usedAt + ", revoked=" + revoked + ", createdAt=" + getCreatedAt() + ", updatedAt=" + updatedAt + "]"; } } From 137972dc130e3e5092ae4886a51774669b08b295 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 10:59:53 +0800 Subject: [PATCH 4/7] Create migration file --- .../migrations/20260715-create-magic-link.xml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml 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..4b611ac063b --- /dev/null +++ b/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + From 28ef972c744846835a0402a8eb85ef151ae01eeb Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 11:02:17 +0800 Subject: [PATCH 5/7] Change expiry time --- src/main/java/teammates/common/util/Const.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 6ecef483600..815a52b0b75 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -37,7 +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.ofHours(1); + public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofMinutes(5); public static final int SEARCH_QUERY_SIZE_LIMIT = 50; From 3f6dfe4f5124757ac9d16f82026d12fea7f89771 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Thu, 16 Jul 2026 09:56:48 +0800 Subject: [PATCH 6/7] Remove naturalId --- src/main/java/teammates/storage/entity/MagicLink.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/teammates/storage/entity/MagicLink.java b/src/main/java/teammates/storage/entity/MagicLink.java index ae77ded7ee9..61bf3265d68 100644 --- a/src/main/java/teammates/storage/entity/MagicLink.java +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -10,7 +10,6 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; -import org.hibernate.annotations.NaturalId; import org.hibernate.annotations.UpdateTimestamp; import teammates.common.util.Const; @@ -27,7 +26,6 @@ public class MagicLink extends BaseEntity { @Id private UUID id; - @NaturalId @Column(nullable = false, unique = true) private String email; From 42ea8dc6ce9d5f2af06144827961dd994912dae9 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Fri, 24 Jul 2026 11:57:21 +0800 Subject: [PATCH 7/7] Remove updatedAt and email unique constraint --- .../teammates/storage/entity/MagicLink.java | 17 ++--------------- .../migrations/20260715-create-magic-link.xml | 2 -- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/main/java/teammates/storage/entity/MagicLink.java b/src/main/java/teammates/storage/entity/MagicLink.java index 61bf3265d68..9e335d3ac92 100644 --- a/src/main/java/teammates/storage/entity/MagicLink.java +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -10,8 +10,6 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; -import org.hibernate.annotations.UpdateTimestamp; - import teammates.common.util.Const; import teammates.common.util.FieldValidator; import teammates.common.util.SanitizationHelper; @@ -26,7 +24,7 @@ public class MagicLink extends BaseEntity { @Id private UUID id; - @Column(nullable = false, unique = true) + @Column(nullable = false) private String email; @Column(nullable = false, unique = true) @@ -35,9 +33,6 @@ public class MagicLink extends BaseEntity { @Column(nullable = false) private Instant expiresAt; - @UpdateTimestamp - private Instant updatedAt; - protected MagicLink() { // required by Hibernate } @@ -90,14 +85,6 @@ public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } - public Instant getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(Instant updatedAt) { - this.updatedAt = updatedAt; - } - /** * Returns true if the magic link expires at or before {@code now}. */ @@ -133,6 +120,6 @@ public int hashCode() { @Override public String toString() { return "MagicLink [id=" + id + ", email=" + email + ", expiresAt=" + expiresAt - + ", createdAt=" + getCreatedAt() + ", updatedAt=" + updatedAt + "]"; + + ", 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 index 4b611ac063b..d7fe03f5d43 100644 --- a/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml +++ b/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml @@ -15,9 +15,7 @@ - -