Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/java/teammates/common/datatransfer/Provider.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
public enum Provider {
TEAMMATES_DEV,
GOOGLE,
EMAIL,
}
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_PAGE = URI_PREFIX + "/email-login";

public static final String ADMIN_HOME_PAGE = ADMIN_PAGE + "/home";
public static final String ADMIN_ACCOUNTS_PAGE = ADMIN_PAGE + "/accounts/{accountId}";
Expand Down
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);
}

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

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package teammates.ui.loginmethodhandlers;

import java.io.IOException;

import jakarta.servlet.http.HttpServletRequest;

import org.apache.http.HttpStatus;

import teammates.common.datatransfer.Provider;
import teammates.common.exception.EntityDoesNotExistException;
import teammates.common.exception.InvalidParametersException;
import teammates.common.util.Config;
import teammates.common.util.Const;
import teammates.common.util.JsonUtils;
import teammates.common.util.Logger;
import teammates.common.util.StringHelper;
import teammates.logic.core.MagicLinksLogic;
import teammates.storage.entity.MagicLink;
import teammates.ui.exception.AuthException;
import teammates.ui.output.LoginMethod;

/**
* Login handler for email magic-link login.
*/
public class EmailLoginHandler implements LoginMethodHandler {

private static final Logger log = Logger.getLogger();

private final MagicLinksLogic magicLinksLogic;

public EmailLoginHandler() {
this(MagicLinksLogic.inst());
}

EmailLoginHandler(MagicLinksLogic magicLinksLogic) {
this.magicLinksLogic = magicLinksLogic;
}

@Override
public String handleLogin(HttpServletRequest req, String nextUrl) throws IOException, AuthException {
AuthState state = new AuthState(nextUrl, req.getSession().getId(), LoginMethod.EMAIL);
String encryptedState = StringHelper.encrypt(JsonUtils.toCompactJson(state));
String redirectUrl = Config.getFrontEndAppUrl(Const.WebPageURIs.EMAIL_LOGIN_PAGE)
.withParam(Const.ParamsNames.AUTH_STATE, encryptedState)
.toAbsoluteString();

log.request(req, HttpStatus.SC_MOVED_TEMPORARILY, "Redirect to email login page");

return redirectUrl;
}

@Override
public AuthResult handleCallback(HttpServletRequest req, AuthState state) throws IOException, AuthException {
String token = req.getParameter(Const.ParamsNames.TOKEN);
if (token == null) {
throw new AuthException("Missing token parameter in email login callback");
}

String sessionId = state.sessionId();
if (!sessionId.equals(req.getSession().getId())) {
String message = String.format("Different session ID: expected %s, got %s",
sessionId, req.getSession().getId());
throw new AuthException(message);
}

MagicLink magicLink;
try {
magicLink = magicLinksLogic.consumeMagicLink(token);
} catch (EntityDoesNotExistException | InvalidParametersException e) {
throw new AuthException("Invalid or expired magic link", e);
}

return new AuthResult(Provider.EMAIL, magicLink.getEmail(), null, magicLink.getEmail());
}
}
1 change: 1 addition & 0 deletions src/main/java/teammates/ui/output/LoginMethod.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
public enum LoginMethod {
GOOGLE("google"),
EMAIL("email"),
DEV_SERVER("devserver");

private final String method;
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/teammates/ui/servlets/AuthServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import teammates.common.util.JsonUtils;
import teammates.common.util.StringHelper;
import teammates.ui.loginmethodhandlers.DevServerLoginHandler;
import teammates.ui.loginmethodhandlers.EmailLoginHandler;
import teammates.ui.loginmethodhandlers.GoogleLoginHandler;
import teammates.ui.loginmethodhandlers.LoginMethodHandler;
import teammates.ui.output.LoginMethod;
Expand All @@ -22,6 +23,7 @@ abstract class AuthServlet extends HttpServlet {

private static final Map<LoginMethod, LoginMethodHandler> LOGIN_HANDLERS = Map.of(
LoginMethod.DEV_SERVER, new DevServerLoginHandler(),
LoginMethod.EMAIL, new EmailLoginHandler(),
LoginMethod.GOOGLE, new GoogleLoginHandler());

Cookie getLoginInvalidationCookie() {
Expand Down
Loading
Loading