TokenManager.java
- /*
- * The coLAB project
- * Copyright (C) 2021-2024 AlbaSim, MEI, HEIG-VD, HES-SO
- *
- * Licensed under the MIT License
- */
- package ch.colabproject.colab.api.controller.token;
- import ch.colabproject.colab.api.Helper;
- import ch.colabproject.colab.api.controller.RequestManager;
- import ch.colabproject.colab.api.controller.card.CardManager;
- import ch.colabproject.colab.api.controller.project.ProjectManager;
- import ch.colabproject.colab.api.controller.security.SecurityManager;
- import ch.colabproject.colab.api.controller.team.AssignmentManager;
- import ch.colabproject.colab.api.controller.team.InstanceMakerManager;
- import ch.colabproject.colab.api.controller.team.TeamManager;
- import ch.colabproject.colab.api.controller.user.UserManager;
- import ch.colabproject.colab.api.model.card.Card;
- import ch.colabproject.colab.api.model.project.InstanceMaker;
- import ch.colabproject.colab.api.model.project.Project;
- import ch.colabproject.colab.api.model.team.TeamMember;
- import ch.colabproject.colab.api.model.team.acl.Assignment;
- import ch.colabproject.colab.api.model.team.acl.HierarchicalPosition;
- import ch.colabproject.colab.api.model.team.acl.InvolvementLevel;
- import ch.colabproject.colab.api.model.token.*;
- import ch.colabproject.colab.api.model.user.HashMethod;
- import ch.colabproject.colab.api.model.user.LocalAccount;
- import ch.colabproject.colab.api.model.user.User;
- import ch.colabproject.colab.api.persistence.jpa.token.TokenDao;
- import ch.colabproject.colab.api.service.smtp.Message;
- import ch.colabproject.colab.api.service.smtp.Sendmail;
- import ch.colabproject.colab.api.setup.ColabConfiguration;
- import ch.colabproject.colab.generator.model.exceptions.HttpErrorMessage;
- import ch.colabproject.colab.generator.model.exceptions.MessageI18nKey;
- import org.slf4j.Logger;
- import org.slf4j.LoggerFactory;
- import javax.ejb.LocalBean;
- import javax.ejb.Stateless;
- import javax.inject.Inject;
- import javax.mail.MessagingException;
- import java.time.OffsetDateTime;
- import java.util.List;
- import java.util.Objects;
- /**
- * Process tokens
- *
- * @author maxence
- */
- @Stateless
- @LocalBean
- public class TokenManager {
- /** logger */
- private static final Logger logger = LoggerFactory.getLogger(TokenManager.class);
- /**
- * to create team member
- */
- @Inject
- private TeamManager teamManager;
- /**
- * to create instanceMaker
- */
- @Inject
- private InstanceMakerManager instanceMakerManager;
- /**
- * Assignment specific logic
- */
- @Inject
- private AssignmentManager assignmentManager;
- /**
- * User and account specific logic
- */
- @Inject
- private UserManager userManager;
- /**
- * Project specific logic
- */
- @Inject
- private ProjectManager projectManager;
- /**
- * Card specific logic
- */
- @Inject
- private CardManager cardManager;
- /**
- * To check access rights
- */
- @Inject
- private SecurityManager securityManager;
- /**
- * Token persistence
- */
- @Inject
- private TokenDao tokenDao;
- /**
- * Request context
- */
- @Inject
- private RequestManager requestManager;
- /**
- * Persist the token
- *
- * @param token token to persist
- */
- private void persistToken(Token token) {
- logger.debug("persist token {}", token);
- // set something to respect notNull constraints
- // otherwise persist will fail
- // These values will be reset when the e-mail is sent.
- if (token.getHashMethod() == null) {
- token.setHashMethod(Helper.getDefaultHashMethod());
- }
- if (token.getHashedToken() == null) {
- token.setHashedToken(new byte[0]);
- }
- tokenDao.persistToken(token);
- }
- /**
- * Finalize initialization of the token and send it to the recipient.
- * <p>
- * As the plain token is not stored in the database, the token is regenerated in this method.
- *
- * @param token the token to send
- * @param recipient recipient email address
- *
- * @throws javax.mail.MessagingException if sending the message fails
- */
- public void sendTokenByEmail(EmailableToken token, String recipient) throws MessagingException {
- logger.debug("Send token {} to {}", token, recipient);
- String url = generateNewRandomPlainToken(token);
- String body = token.getEmailBody(url);
- // this log message contains sensitive information (body contains the plain-text token)
- logger.trace("Send token {} to {} with body {}", token, recipient, body);
- Sendmail.send(
- Message.create()
- .from("noreply@" + ColabConfiguration.getSmtpDomain())
- .to(recipient)
- .subject(token.getSubject())
- .htmlBody(body)
- .build()
- );
- }
- /**
- * Generate a new random plain token.
- * <p>
- * For security reason, its value is not stored in database. The knowledge is split between
- * hash data that are stored in database and a URL containing the plain token.
- *
- * @param token The token (its values will be changed)
- *
- * @return the URL which enables the token to be consumed
- */
- private String generateNewRandomPlainToken(TokenWithURL token) {
- String plainToken = Helper.generateHexSalt(64);
- HashMethod hashMethod = Helper.getDefaultHashMethod();
- byte[] hashedToken = hashMethod.hash(plainToken, Token.SALT);
- token.setHashMethod(hashMethod);
- token.setHashedToken(hashedToken);
- String baseUrl = requestManager.getBaseUrl();
- return baseUrl + "/#/token/" + token.getId() + "/" + plainToken;
- }
- /**
- * Consume the token
- *
- * @param id the id of the token to consume
- * @param plainToken the plain secret token as sent by e-mail
- *
- * @return the consumed token
- *
- * @throws HttpErrorMessage notFound if the token does not exist;<br>
- * badRequest if token does not match;<br>
- * authenticationRequired if token requires authentication but current
- * user is not
- */
- public Token consume(Long id, String plainToken) {
- logger.debug("Consume token #{}", id);
- Token token = tokenDao.findToken(id);
- if (token != null) {
- if (token.isAuthenticationRequired() && !requestManager.isAuthenticated()) {
- logger.debug("Token requires an authenticated user");
- throw HttpErrorMessage.authenticationRequired();
- } else {
- if (token.checkHash(plainToken)) {
- requestManager.sudo(() -> {
- boolean isConsumed = token.consume(this);
- if (isConsumed
- && token.getExpirationPolicy() == ExpirationPolicy.ONE_SHOT) {
- tokenDao.deleteToken(token);
- }
- });
- return token;
- } else {
- logger.debug("Provided plain-token does not match");
- throw HttpErrorMessage.badRequest();
- }
- }
- } else {
- logger.debug("There is no token #{}", id);
- throw HttpErrorMessage.notFound();
- }
- }
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // Verify email address
- ////////////////////////////////////////////////////////////////////////////////////////////////
- /**
- * Create or update a validation token.
- * <p>
- * If a validate token already exists for the given account, it will be updated so there is never
- * more than one validation token per localAccount.
- *
- * @param account token owner
- *
- * @return a brand-new token or a refresh
- */
- private VerifyLocalAccountToken getOrCreateVerifyAccountToken(LocalAccount account) {
- logger.debug("getOrCreate VerifyToken for {}", account);
- VerifyLocalAccountToken token = tokenDao.findVerifyTokenByAccount(account);
- if (token == null) {
- logger.debug("no token, create one");
- token = new VerifyLocalAccountToken();
- token.setAuthenticationRequired(false);
- token.setLocalAccount(account);
- persistToken(token);
- }
- // token.setExpirationDate(OffsetDateTime.now().plus(1, ChronoUnit.WEEKS));
- token.setExpirationDate(null);
- return token;
- }
- /**
- * Send a "Please verify your email address" message.
- *
- * @param account account to verify
- * @param failsOnError if false, silent SMTP error
- *
- * @throws HttpErrorMessage smtpError if there is an SMTP error AND failsOnError is set to true
- * messageError if the message contains errors (e.g. malformed
- * addresses)
- */
- public void requestEmailAddressVerification(LocalAccount account, boolean failsOnError) {
- try {
- VerifyLocalAccountToken token = this.getOrCreateVerifyAccountToken(account);
- sendTokenByEmail(token, account.getEmail());
- } catch (MessagingException ex) {
- logger.error("Fails to send email address verification email", ex);
- if (failsOnError) {
- throw HttpErrorMessage.smtpError();
- }
- }
- }
- /**
- * Consume the local account verification token
- *
- * @param account the account related to the token
- *
- * @return true if the token can be consumed
- */
- public boolean consumeVerifyAccountToken(LocalAccount account) {
- userManager.setLocalAccountAsVerified(account);
- return true;
- }
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // RESET PASSWORD
- ////////////////////////////////////////////////////////////////////////////////////////////////
- /**
- * get existing reset password token if it exists or create new one otherwise.
- *
- * @param account token owner
- *
- * @return the token to user
- */
- private ResetLocalAccountPasswordToken getOrCreateResetToken(LocalAccount account) {
- logger.debug("getOrCreate Reset for {}", account);
- ResetLocalAccountPasswordToken token = tokenDao.findResetTokenByAccount(account);
- if (token == null) {
- token = new ResetLocalAccountPasswordToken();
- logger.debug("no token, create one");
- token.setAuthenticationRequired(false);
- token.setLocalAccount(account);
- persistToken(token);
- }
- token.setExpirationDate(OffsetDateTime.now().plusHours(1));
- return token;
- }
- /**
- * Send a "Click here the reset your password" message.
- *
- * @param account The account whose password is to be reset
- * @param failsOnError if false, silent SMTP error
- *
- * @throws HttpErrorMessage smtpError if there is an SMTP error AND failsOnError is set to true
- * messageError if the message contains errors (e.g. malformed
- * addresses)
- */
- public void sendResetPasswordToken(LocalAccount account, boolean failsOnError) {
- try {
- logger.debug("Send reset password token to {}", account);
- ResetLocalAccountPasswordToken token = this.getOrCreateResetToken(account);
- sendTokenByEmail(token, account.getEmail());
- } catch (MessagingException ex) {
- logger.error("Failed to send password reset email", ex);
- if (failsOnError) {
- throw HttpErrorMessage.smtpError();
- }
- }
- }
- /**
- * Consume the given reset password login.
- *
- * @param account the account related to the token
- *
- * @return true if the token can be consumed
- */
- public boolean consumeResetPasswordToken(LocalAccount account) {
- requestManager.login(account);
- return true;
- }
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // Invite a new team member
- ////////////////////////////////////////////////////////////////////////////////////////////////
- /**
- * Send invitation to join the project team to the recipient.
- *
- * @param project the project to join
- * @param recipient email address to send invitation to
- *
- * @return the pending teamMember of null if none was sent
- */
- public TeamMember sendMembershipInvitation(Project project, String recipient) {
- User currentUser = securityManager.assertAndGetCurrentUser();
- InvitationToken token = tokenDao.findInvitationByProjectAndRecipient(project, recipient);
- if (token == null) {
- // create a member and link it to the project, but do not link it to any user
- // this link will be set during token consumption
- TeamMember newMember = teamManager.addMember(project, null,
- HierarchicalPosition.INTERNAL);
- token = new InvitationToken();
- token.setTeamMember(newMember);
- // never expire
- token.setExpirationDate(null);
- token.setAuthenticationRequired(Boolean.TRUE);
- token.setRecipient(recipient);
- newMember.setDisplayName(recipient);
- persistToken(token);
- }
- token.setSender(currentUser.getDisplayName());
- try {
- sendTokenByEmail(token, recipient);
- } catch (MessagingException ex) {
- logger.error("Failed to send membership invitation email", ex);
- throw HttpErrorMessage.smtpError();
- }
- return token.getTeamMember();
- }
- /**
- * Delete all invitations linked to the team member
- *
- * @param teamMember the team member for which we delete all invitations
- */
- public void deleteInvitationsByTeamMember(TeamMember teamMember) {
- List<InvitationToken> invitations = tokenDao.findInvitationByTeamMember(teamMember);
- invitations.forEach(token -> tokenDao.deleteToken(token));
- }
- /**
- * Consume the invitation token
- *
- * @param teamMember the team member related to the token
- *
- * @return true if the token can be consumed
- */
- public boolean consumeInvitationToken(TeamMember teamMember) {
- User user = requestManager.getCurrentUser();
- if (user == null) {
- throw HttpErrorMessage.authenticationRequired();
- }
- Project project = teamMember.getProject();
- TeamMember existingTeamMember = teamManager.findMemberByProjectAndUser(project, user);
- if (existingTeamMember != null) {
- throw HttpErrorMessage
- .tokenProcessingFailure(MessageI18nKey.USER_IS_ALREADY_A_TEAM_MEMBER);
- }
- teamMember.setUser(user);
- teamMember.setDisplayName(null);
- return true;
- }
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // Share a model to someone
- ////////////////////////////////////////////////////////////////////////////////////////////////
- /**
- * Send a model sharing token to register the project as a model to use
- * <p>
- * If the token does not exist yet, create it and link to it a new pending instance maker.
- *
- * @param model the id of the model
- * @param recipient the address to send the sharing token to
- *
- * @return the pending instance maker
- */
- public InstanceMaker sendModelSharingToken(Project model, String recipient) {
- User currentUser = securityManager.assertAndGetCurrentUser();
- ModelSharingToken token = tokenDao.findModelSharingByProjectAndRecipient(model, recipient);
- if (token == null) {
- // create an instance maker and link it to the project, but do not link it to any user
- // this link will be set during token consumption
- InstanceMaker newInstanceMaker = instanceMakerManager.addAndPersistInstanceMaker(model, null);
- token = new ModelSharingToken();
- token.setInstanceMaker(newInstanceMaker);
- token.setExpirationDate(null);
- token.setAuthenticationRequired(Boolean.TRUE);
- token.setRecipient(recipient);
- newInstanceMaker.setDisplayName(recipient);
- persistToken(token);
- }
- token.setSender(currentUser.getDisplayName());
- try {
- sendTokenByEmail(token, recipient);
- } catch (MessagingException ex) {
- logger.error("Failed to send model sharing email", ex);
- throw HttpErrorMessage.smtpError();
- }
- return token.getInstanceMaker();
- }
- /**
- * Delete all model sharing tokens linked to the instance maker
- *
- * @param instanceMaker the instance maker for which we delete all invitations
- */
- public void deleteModelSharingTokenByInstanceMaker(InstanceMaker instanceMaker) {
- List<ModelSharingToken> tokens = tokenDao.findModelSharingByInstanceMaker(instanceMaker);
- tokens.forEach(token -> tokenDao.deleteToken(token));
- }
- /**
- * Consume the model sharing token
- *
- * @param instanceMaker the instance maker related to the token
- *
- * @return true if the token can be consumed
- */
- public boolean consumeModelSharingToken(InstanceMaker instanceMaker) {
- User user = requestManager.getCurrentUser();
- if (user == null) {
- throw HttpErrorMessage.authenticationRequired();
- }
- Project model = instanceMaker.getProject();
- InstanceMaker existingInstanceMaker = instanceMakerManager
- .findInstanceMakerByProjectAndUser(model, user);
- if (existingInstanceMaker != null) {
- throw HttpErrorMessage.tokenProcessingFailure(
- MessageI18nKey.CURRENT_USER_CAN_ALREADY_USE_MODEL);
- }
- instanceMaker.setUser(user);
- instanceMaker.setDisplayName(null);
- return true;
- }
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // Share a project card to enable people (not defined who) to edit it
- ////////////////////////////////////////////////////////////////////////////////////////////////
- /**
- * Create a token to share the project.
- *
- * @param project The project that will become visible (mandatory)
- * @param card The card that will become editable (optional)
- *
- * @return the URL to use to consume the token
- */
- public String generateSharingLinkToken(Project project, Card card) {
- if (project == null) {
- throw HttpErrorMessage.badRequest();
- }
- boolean isCurrentUserInternalToProject = securityManager.isCurrentUserInternalToProject(project);
- if (!isCurrentUserInternalToProject) {
- throw HttpErrorMessage.forbidden();
- }
- if (card != null) {
- boolean canCurrentUserEditCard = securityManager.hasReadWriteAccess(card);
- if (!canCurrentUserEditCard) {
- throw HttpErrorMessage.forbidden();
- }
- }
- // we never re-use an existing token
- // because when we generate the URL, it changes the hash data and so invalidate existing token.
- SharingLinkToken token = new SharingLinkToken();
- token.setProjectId(project.getId());
- token.setCardId(card != null ? card.getId() : null);
- token.setAuthenticationRequired(Boolean.TRUE);
- token.setExpirationDate(null);
- persistToken(token);
- return generateNewRandomPlainToken(token);
- }
- /**
- * Delete all sharing link tokens for the given project.
- *
- * @param project the project
- */
- public void deleteSharingLinkTokensByProject(Project project) {
- List<SharingLinkToken> sharingLinkTokens = tokenDao.findSharingLinkByProject(project);
- sharingLinkTokens.forEach(token -> tokenDao.deleteToken(token));
- }
- /**
- * Delete all sharing link tokens for the given card.
- *
- * @param card the card
- */
- public void deleteSharingLinkTokensByCard(Card card) {
- List<SharingLinkToken> sharingLinkTokens = tokenDao.findSharingLinkByCard(card);
- sharingLinkTokens.forEach(token -> tokenDao.deleteToken(token));
- }
- /**
- * Consume the token to ensure that the user can view the project and edit the card (if set)
- *
- * @param projectId The id of the project that will become visible (mandatory)
- * @param cardId The id of the card that will become editable (optional)
- *
- * @return true if it could happen
- */
- public boolean consumeSharingLinkToken(Long projectId, Long cardId) {
- User user = requestManager.getCurrentUser();
- Project project = projectManager.assertAndGetProject(projectId);
- Card card = cardManager.assertAndGetCard(cardId);
- if (user == null) {
- throw HttpErrorMessage.authenticationRequired();
- }
- TeamMember teamMember = teamManager.findMemberByProjectAndUser(project, user);
- if (teamMember == null) {
- teamMember = teamManager.addMember(project, user, HierarchicalPosition.GUEST);
- }
- if (card != null) {
- if (!Objects.equals(card.getProject(), project)) {
- throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
- }
- List<Assignment> assignments =
- assignmentManager.getAssignmentsForCardAndTeamMember(card, teamMember);
- if (assignments.isEmpty()) {
- assignmentManager.setAssignment(card.getId(), teamMember.getId(),
- InvolvementLevel.SUPPORT);
- }
- }
- return true;
- }
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // for each token
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // /**
- // * Delete all invitations linked to the project
- // *
- // * @param project the project for which we delete all tokens
- // */
- // public void deleteTokensByProject(Project project) {
- // List<Token> tokens = tokenDao.findTokensByProject(project);
- // tokens.stream().forEach(token -> tokenDao.deleteToken(token));
- // }
- /**
- * Fetch token with given id from DAO. If it's outdated, it will be destroyed and null will be
- * returned
- *
- * @param id id of the token
- *
- * @return token if it exists and is not outdated, null otherwise
- */
- public Token getNotExpiredToken(Long id) {
- Token token = tokenDao.findToken(id);
- if (token != null && token.isOutdated()) {
- requestManager.sudo(() -> tokenDao.deleteToken(token));
- return null;
- }
- return token;
- }
- }