Skip to content
33 changes: 33 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,36 @@ 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);
}

/**
* 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
3 changes: 3 additions & 0 deletions src/main/java/teammates/logic/core/LogicStarter.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 +50,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 Down Expand Up @@ -84,6 +86,7 @@ public static void initializeDependencies() {
fqLogic.initLogicDependencies(FeedbackQuestionsDb.inst(), coursesLogic, frLogic, usersLogic, fsLogic,
instructorPermissionsLogic);
notificationsLogic.initLogicDependencies(NotificationsDb.inst(), accountsLogic);
magicLinksLogic.initLogicDependencies(MagicLinksDb.inst());
usageStatisticsLogic.initLogicDependencies(frLogic, coursesLogic, usersLogic, accountVerificationsLogic);
enrollmentLogic.initLogicDependencies(usersLogic, coursesLogic, fsLogic);
usersLogic.initLogicDependencies(UsersDb.inst(), coursesLogic, courseJoinEmailsLogic,
Expand Down
118 changes: 118 additions & 0 deletions src/main/java/teammates/logic/core/MagicLinksLogic.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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.StringHelper;
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 MagicLinksLogic() {
// prevent initialization
}

public static MagicLinksLogic inst() {
return instance;
}

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

/**
* 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 {
// TODO: Add a method to send the magic link to the user via email.
Objects.requireNonNull(email);

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

magicLinksDb.persistMagicLink(magicLink);
return token;
}

/**
* 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was the 'magic-link:' prefix added here? What purpose does this serve?

}

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());
}
}

}
106 changes: 106 additions & 0 deletions src/test/java/teammates/logic/core/MagicLinksLogicTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package teammates.logic.core;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.time.Instant;

import org.mockito.ArgumentCaptor;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import teammates.common.exception.EntityDoesNotExistException;
import teammates.common.exception.InvalidParametersException;
import teammates.storage.api.MagicLinksDb;
import teammates.storage.entity.MagicLink;
import teammates.test.BaseTestCase;

/**
* SUT: {@link MagicLinksLogic}.
*/
public class MagicLinksLogicTest extends BaseTestCase {

private MagicLinksLogic magicLinksLogic = MagicLinksLogic.inst();

private MagicLinksDb magicLinksDb;

@BeforeMethod
public void setUpMethod() {
magicLinksDb = mock(MagicLinksDb.class);
magicLinksLogic.initLogicDependencies(magicLinksDb);
}

@Test
public void createMagicLink_validEmail_generatesTokenAndStoresHash() throws InvalidParametersException {
when(magicLinksDb.persistMagicLink(any(MagicLink.class))).thenAnswer(inv -> inv.getArgument(0));

String token = magicLinksLogic.createMagicLink("user@example.com");

ArgumentCaptor<MagicLink> captor = ArgumentCaptor.forClass(MagicLink.class);
verify(magicLinksDb, times(1)).persistMagicLink(captor.capture());
MagicLink persistedMagicLink = captor.getValue();

assertEquals("user@example.com", persistedMagicLink.getEmail());
assertEquals(MagicLinksLogic.hashToken(token), persistedMagicLink.getTokenHash());
}

@Test
public void createMagicLink_invalidEmail_throwsInvalidParametersException() {
assertThrows(InvalidParametersException.class, () -> magicLinksLogic.createMagicLink("invalid-email"));

verify(magicLinksDb, never()).persistMagicLink(any(MagicLink.class));
}

@Test
public void getMagicLinkByToken_tokenExists_returnsMagicLink() {
String token = "raw-token";
MagicLink magicLink = new MagicLink("user@example.com", MagicLinksLogic.hashToken(token), Instant.now());
when(magicLinksDb.getMagicLinkByTokenHash(MagicLinksLogic.hashToken(token))).thenReturn(magicLink);

MagicLink actual = magicLinksLogic.getMagicLinkByToken(token);

assertEquals(magicLink, actual);
}

@Test
public void consumeMagicLink_magicLinkIsUsable_deletesAndReturnsMagicLink()
throws EntityDoesNotExistException, InvalidParametersException {
String token = "raw-token";
MagicLink magicLink = new MagicLink("user@example.com", MagicLinksLogic.hashToken(token), Instant.now());
when(magicLinksDb.getMagicLinkByTokenHash(MagicLinksLogic.hashToken(token))).thenReturn(magicLink);

MagicLink actual = magicLinksLogic.consumeMagicLink(token);

assertEquals(magicLink, actual);
verify(magicLinksDb, times(1)).deleteMagicLink(magicLink);
}

@Test
public void consumeMagicLink_magicLinkIsExpired_throwsInvalidParametersExceptionWithoutDeleting() {
String token = "raw-token";
MagicLink magicLink = new MagicLink("user@example.com", MagicLinksLogic.hashToken(token), Instant.now());
magicLink.setExpiresAt(Instant.now().minusSeconds(1));
when(magicLinksDb.getMagicLinkByTokenHash(MagicLinksLogic.hashToken(token))).thenReturn(magicLink);

InvalidParametersException ex = assertThrows(
InvalidParametersException.class, () -> magicLinksLogic.consumeMagicLink(token));

assertEquals("Invalid or expired magic link.", ex.getMessage());
verify(magicLinksDb, never()).deleteMagicLink(any(MagicLink.class));
}

@Test
public void consumeMagicLink_magicLinkDoesNotExist_throwsEntityDoesNotExistException() {
EntityDoesNotExistException ex = assertThrows(
EntityDoesNotExistException.class, () -> magicLinksLogic.consumeMagicLink("raw-token"));

assertEquals("Magic link does not exist for the given token.", ex.getMessage());
verify(magicLinksDb, never()).deleteMagicLink(any(MagicLink.class));
}
}
Loading