Skip to content
Draft
2 changes: 2 additions & 0 deletions src/main/java/teammates/common/util/Const.java
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ public static class ParamsNames {
public static final String LOGIN_METHOD = "loginMethod";
public static final String EMAIL = "email";
public static final String AUTH_STATE = "state";
public static final String TOKEN = "token";
}

/**
Expand Down Expand Up @@ -237,6 +238,7 @@ public static class WebPageURIs {
private static final String MAINTAINER_PAGE = URI_PREFIX + "/" + EntityType.MAINTAINER;
public static final String FRONT_PAGE = URI_PREFIX + "/front";
public static final String JOIN_PAGE = URI_PREFIX + "/join";
public static final String EMAIL_LOGIN_CONFIRMATION_PAGE = URI_PREFIX + "/email-login/confirm";

public static final String ADMIN_HOME_PAGE = ADMIN_PAGE + "/home";
public static final String ADMIN_ACCOUNTS_PAGE = ADMIN_PAGE + "/accounts/{accountId}";
Expand Down
1 change: 1 addition & 0 deletions src/main/java/teammates/common/util/EmailType.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public enum EmailType {
STUDENT_COURSE_LINKS_REGENERATED("TEAMMATES: Your updated links for course [%s][Course ID: %s]"),
INSTRUCTOR_COURSE_LINKS_REGENERATED("TEAMMATES: Your updated links for course [%s][Course ID: %s]"),
ACCOUNT_VERIFICATION_APPROVED("TEAMMATES: Welcome to TEAMMATES, %s!"),
MAGIC_LINK_LOGIN("TEAMMATES: Sign in to TEAMMATES"),
STUDENT_COURSE_JOIN("TEAMMATES: Invitation to join course [%s][Course ID: %s]"),
STUDENT_COURSE_REJOIN_AFTER_UNLINK_ACCOUNT("TEAMMATES: Your account has been unlinked from course [%s][Course ID: %s]"),
NEW_ACCOUNT_VERIFICATION_REQUEST_ADMIN_ALERT("TEAMMATES (Action Needed): New Account Verification Request Received"),
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/teammates/common/util/Templates.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ public static class EmailTemplates {
FileHelper.readResourceFile("accountVerificationApproved.html");
public static final String ACCOUNT_VERIFICATION_REJECTED =
FileHelper.readResourceFile("accountVerificationRejected.html");
public static final String MAGIC_LINK_LOGIN =
FileHelper.readResourceFile("userEmailTemplate-magicLinkLogin.html");
public static final String FRAGMENT_SESSION_ADDITIONAL_CONTACT_INFORMATION =
FileHelper.readResourceFile("userEmailFragment-sessionAdditionalContactInformationFragment.html");
public static final String USER_DEADLINE_EXTENSION =
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/teammates/logic/api/Logic.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import teammates.logic.core.FeedbackSessionsLogic;
import teammates.logic.core.InstitutesLogic;
import teammates.logic.core.InstructorPermissionsLogic;
import teammates.logic.core.MagicLinksLogic;
import teammates.logic.core.NotificationsLogic;
import teammates.logic.core.ResponseInstructorCommentsLogic;
import teammates.logic.core.UsageStatisticsLogic;
Expand All @@ -66,6 +67,7 @@
import teammates.storage.entity.FeedbackSessionLog;
import teammates.storage.entity.Institute;
import teammates.storage.entity.Instructor;
import teammates.storage.entity.MagicLink;
import teammates.storage.entity.Notification;
import teammates.storage.entity.ReadNotification;
import teammates.storage.entity.ResponseInstructorComment;
Expand Down Expand Up @@ -115,6 +117,7 @@ public class Logic {
final UsageStatisticsLogic usageStatisticsLogic = UsageStatisticsLogic.inst();
final UsersLogic usersLogic = UsersLogic.inst();
final NotificationsLogic notificationsLogic = NotificationsLogic.inst();
final MagicLinksLogic magicLinksLogic = MagicLinksLogic.inst();
final DataBundleLogic dataBundleLogic = DataBundleLogic.inst();
final InstructorPermissionsLogic instructorPermissionsLogic = InstructorPermissionsLogic.inst();

Expand Down Expand Up @@ -1154,6 +1157,43 @@ public List<Notification> getNotificationsByTargetUsers(
return notificationsLogic.getNotificationsByTargetUsers(targetUsers, isActiveOnly);
}

/**
* Creates or replaces a magic link for the given email address.
*
* @return the raw one-time token.
* @throws InvalidParametersException if the magic link is not valid.
*/
public String createMagicLink(String email) throws InvalidParametersException {
return magicLinksLogic.createMagicLink(email);
}

/**
* Creates a magic link and enqueues the sign-in email for the given email address.
*/
public void requestMagicLinkEmail(String email, String encryptedState) throws InvalidParametersException {
magicLinksLogic.requestMagicLinkEmail(email, encryptedState);
}

/**
* Returns a magic link for the given raw token, or null if no matching link exists.
*/
public MagicLink getMagicLinkByToken(String token) {
return magicLinksLogic.getMagicLinkByToken(token);
}

/**
* Consumes a usable magic link for the given raw token.
*
* <p>Successful consumption deletes the magic link to enforce one-time use.
*
* @return the consumed magic link.
* @throws EntityDoesNotExistException if the token is unknown.
* @throws InvalidParametersException if the token is not usable.
*/
public MagicLink consumeMagicLink(String token) throws EntityDoesNotExistException, InvalidParametersException {
return magicLinksLogic.consumeMagicLink(token);
}

/**
* Unlinks the account associated with the user profile without deleting
* either entity, allowing the profile to be linked to a different account.
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/teammates/logic/core/LogicStarter.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import teammates.logic.email.DeadlineExtensionEmailsLogic;
import teammates.logic.email.EmailQueueService;
import teammates.logic.email.FeedbackSessionEmailsLogic;
import teammates.logic.email.MagicLinkEmailsLogic;
import teammates.storage.api.AccountVerificationRequestsDb;
import teammates.storage.api.AccountsDb;
import teammates.storage.api.CoursesDb;
Expand All @@ -19,6 +20,7 @@
import teammates.storage.api.FeedbackSessionsDb;
import teammates.storage.api.InstitutesDb;
import teammates.storage.api.InstructorPermissionsDb;
import teammates.storage.api.MagicLinksDb;
import teammates.storage.api.NotificationsDb;
import teammates.storage.api.ResponseInstructorCommentsDb;
import teammates.storage.api.UsersDb;
Expand Down Expand Up @@ -49,6 +51,7 @@ public static void initializeDependencies() {
ResponseInstructorCommentsLogic frcLogic = ResponseInstructorCommentsLogic.inst();
FeedbackQuestionsLogic fqLogic = FeedbackQuestionsLogic.inst();
NotificationsLogic notificationsLogic = NotificationsLogic.inst();
MagicLinksLogic magicLinksLogic = MagicLinksLogic.inst();
UsageStatisticsLogic usageStatisticsLogic = UsageStatisticsLogic.inst();
UsersLogic usersLogic = UsersLogic.inst();
InstructorPermissionsLogic instructorPermissionsLogic = InstructorPermissionsLogic.inst();
Expand All @@ -57,12 +60,14 @@ public static void initializeDependencies() {
DeadlineExtensionEmailsLogic deadlineExtensionEmailsLogic = DeadlineExtensionEmailsLogic.inst();
FeedbackSessionEmailsLogic feedbackSessionEmailsLogic = FeedbackSessionEmailsLogic.inst();
AccountVerificationEmailsLogic accountVerificationEmailsLogic = AccountVerificationEmailsLogic.inst();
MagicLinkEmailsLogic magicLinkEmailsLogic = MagicLinkEmailsLogic.inst();
EmailQueueService emailQueueService = EmailQueueService.inst();

courseJoinEmailsLogic.init(emailQueueService);
deadlineExtensionEmailsLogic.init(emailQueueService);
feedbackSessionEmailsLogic.init(emailQueueService);
accountVerificationEmailsLogic.init(emailQueueService);
magicLinkEmailsLogic.init(emailQueueService);

authLogic.initLogicDependencies(usersLogic);
institutesLogic.initLogicDependencies(InstitutesDb.inst());
Expand All @@ -84,6 +89,7 @@ public static void initializeDependencies() {
fqLogic.initLogicDependencies(FeedbackQuestionsDb.inst(), coursesLogic, frLogic, usersLogic, fsLogic,
instructorPermissionsLogic);
notificationsLogic.initLogicDependencies(NotificationsDb.inst(), accountsLogic);
magicLinksLogic.initLogicDependencies(MagicLinksDb.inst(), magicLinkEmailsLogic);
usageStatisticsLogic.initLogicDependencies(frLogic, coursesLogic, usersLogic, accountVerificationsLogic);
enrollmentLogic.initLogicDependencies(usersLogic, coursesLogic, fsLogic);
usersLogic.initLogicDependencies(UsersDb.inst(), coursesLogic, courseJoinEmailsLogic,
Expand Down
138 changes: 138 additions & 0 deletions src/main/java/teammates/logic/core/MagicLinksLogic.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package teammates.logic.core;

import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Objects;

import teammates.common.exception.EntityDoesNotExistException;
import teammates.common.exception.InvalidParametersException;
import teammates.common.util.Config;
import teammates.common.util.Const;
import teammates.common.util.StringHelper;
import teammates.logic.email.MagicLinkEmailsLogic;
import teammates.logic.email.model.MagicLinkEmailContext;
import teammates.storage.api.MagicLinksDb;
import teammates.storage.entity.MagicLink;

/**
* Handles operations related to magic links.
*
* @see MagicLink
* @see MagicLinksDb
*/
public final class MagicLinksLogic {

private static final MagicLinksLogic instance = new MagicLinksLogic();
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final int TOKEN_BYTE_LENGTH = 32;

private MagicLinksDb magicLinksDb;
private MagicLinkEmailsLogic magicLinkEmailsLogic;

private MagicLinksLogic() {
// prevent initialization
}

public static MagicLinksLogic inst() {
return instance;
}

void initLogicDependencies(MagicLinksDb magicLinksDb, MagicLinkEmailsLogic magicLinkEmailsLogic) {
this.magicLinksDb = magicLinksDb;
this.magicLinkEmailsLogic = magicLinkEmailsLogic;
}

/**
* Creates or replaces a magic link for the given email address.
*
* @return the raw one-time token.
* @throws InvalidParametersException if the magic link is not valid.
*/
public String createMagicLink(String email) throws InvalidParametersException {
Objects.requireNonNull(email);

String token = generateToken();
MagicLink magicLink = new MagicLink(email, hashToken(token), Instant.now());
validateMagicLink(magicLink);

magicLinksDb.persistMagicLink(magicLink);
return token;
}

/**
* Creates a magic link and enqueues the sign-in email for the given email address.
*/
public void requestMagicLinkEmail(String email, String encryptedState) throws InvalidParametersException {
Objects.requireNonNull(encryptedState);

String token = createMagicLink(email);
String magicLinkUrl = Config.getFrontEndAppUrl(Const.WebPageURIs.EMAIL_LOGIN_CONFIRMATION_PAGE)
.withParam(Const.ParamsNames.AUTH_STATE, encryptedState)
.withParam(Const.ParamsNames.TOKEN, token)
.toAbsoluteString();

magicLinkEmailsLogic.enqueueMagicLinkEmail(new MagicLinkEmailContext(email, magicLinkUrl));
}

/**
* Returns a magic link for the given raw token, or null if no matching link exists.
*/
public MagicLink getMagicLinkByToken(String token) {
Objects.requireNonNull(token);
return magicLinksDb.getMagicLinkByTokenHash(hashToken(token));
}

/**
* Consumes a usable magic link for the given raw token.
*
* <p>Successful consumption deletes the magic link to enforce one-time use.
*
* @return the consumed magic link.
* @throws EntityDoesNotExistException if the token is unknown.
* @throws InvalidParametersException if the token is not usable.
*/
public MagicLink consumeMagicLink(String token) throws InvalidParametersException, EntityDoesNotExistException {
Objects.requireNonNull(token);
MagicLink magicLink = getMagicLinkByToken(token);
if (magicLink == null) {
throw new EntityDoesNotExistException("Magic link does not exist for the given token.");
}

if (!magicLink.isUsable(Instant.now())) {
throw new InvalidParametersException("Invalid or expired magic link.");
}

magicLinksDb.deleteMagicLink(magicLink);
return magicLink;
}

/**
* Deletes a magic link.
*/
public void deleteMagicLink(MagicLink magicLink) {
Objects.requireNonNull(magicLink);
magicLinksDb.deleteMagicLink(magicLink);
}

/**
* Hashes a raw magic-link token for storage or lookup.
*/
static String hashToken(String token) {
Objects.requireNonNull(token);
return StringHelper.generateSha256Hmac("magic-link:" + token);
}

private static String generateToken() {
byte[] tokenBytes = new byte[TOKEN_BYTE_LENGTH];
SECURE_RANDOM.nextBytes(tokenBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
}

private void validateMagicLink(MagicLink magicLink) throws InvalidParametersException {
if (!magicLink.isValid()) {
throw new InvalidParametersException(magicLink.getInvalidityInfo());
}
}

}
10 changes: 10 additions & 0 deletions src/main/java/teammates/logic/email/EmailRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import teammates.logic.email.model.FeedbackSessionResultsPreviewEmailContext;
import teammates.logic.email.model.FeedbackSessionSummaryEmailContext;
import teammates.logic.email.model.InstructorCourseJoinEmailContext;
import teammates.logic.email.model.MagicLinkEmailContext;
import teammates.logic.email.model.RenderedEmail;
import teammates.logic.email.model.SessionAccessLink;
import teammates.logic.email.model.SessionLinksRecoveryContext;
Expand Down Expand Up @@ -404,6 +405,15 @@ public static RenderedEmail renderAccountVerificationApprovedEmail(
"${supportEmail}", Config.SUPPORT_EMAIL));
}

/**
* Renders the magic-link sign-in email body.
*/
public static RenderedEmail renderMagicLinkEmail(MagicLinkEmailContext context) {
return new RenderedEmail(Templates.populateTemplate(
EmailTemplates.MAGIC_LINK_LOGIN,
"${magicLinkUrl}", context.magicLinkUrl()));
}

/**
* Renders the rejection email body for a rejected account verification
* request.
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/teammates/logic/email/MagicLinkEmailsLogic.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package teammates.logic.email;

import teammates.common.util.EmailType;
import teammates.common.util.EmailWrapper;
import teammates.logic.email.model.MagicLinkEmailContext;
import teammates.logic.email.model.RenderedEmail;

/**
* Handles email-specific orchestration for magic-link sign-in use cases.
*/
public class MagicLinkEmailsLogic {

private static final MagicLinkEmailsLogic instance = new MagicLinkEmailsLogic();

private EmailQueueService emailQueueService;

public static MagicLinkEmailsLogic inst() {
return instance;
}

/**
* Initializes the outbound email queue dependency.
*/
public void init(EmailQueueService emailQueueService) {
this.emailQueueService = emailQueueService;
}

/**
* Enqueues a magic-link sign-in email.
*/
public void enqueueMagicLinkEmail(MagicLinkEmailContext context) {
RenderedEmail renderedEmail = EmailRenderer.renderMagicLinkEmail(context);
EmailWrapper email = EmailWrapperBuilder.build(
context.recipientEmailAddress(),
EmailType.MAGIC_LINK_LOGIN,
renderedEmail);
emailQueueService.enqueuePriority(email);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package teammates.logic.email.model;

/**
* Email context for a magic-link sign-in email.
*
* @param recipientEmailAddress the recipient email address
* @param magicLinkUrl the confirmation-page URL containing the auth state and one-time token
*/
public record MagicLinkEmailContext(String recipientEmailAddress, String magicLinkUrl) {
}
18 changes: 18 additions & 0 deletions src/main/resources/userEmailTemplate-magicLinkLogin.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<p>Hello,</p>

<p>
Use the link below to continue signing in to TEAMMATES.
</p>

<p>
<a href="${magicLinkUrl}" target="_blank" rel="noopener noreferrer">${magicLinkUrl}</a>
</p>

<p>
This link expires shortly and can be used only once. If you did not request this email, you can ignore it.
</p>

<p>
Regards,
<br>TEAMMATES Team.
</p>
Loading
Loading