TokenManager.java

  1. /*
  2.  * The coLAB project
  3.  * Copyright (C) 2021-2024 AlbaSim, MEI, HEIG-VD, HES-SO
  4.  *
  5.  * Licensed under the MIT License
  6.  */
  7. package ch.colabproject.colab.api.controller.token;

  8. import ch.colabproject.colab.api.Helper;
  9. import ch.colabproject.colab.api.controller.RequestManager;
  10. import ch.colabproject.colab.api.controller.card.CardManager;
  11. import ch.colabproject.colab.api.controller.project.ProjectManager;
  12. import ch.colabproject.colab.api.controller.security.SecurityManager;
  13. import ch.colabproject.colab.api.controller.team.AssignmentManager;
  14. import ch.colabproject.colab.api.controller.team.InstanceMakerManager;
  15. import ch.colabproject.colab.api.controller.team.TeamManager;
  16. import ch.colabproject.colab.api.controller.user.UserManager;
  17. import ch.colabproject.colab.api.model.card.Card;
  18. import ch.colabproject.colab.api.model.project.InstanceMaker;
  19. import ch.colabproject.colab.api.model.project.Project;
  20. import ch.colabproject.colab.api.model.team.TeamMember;
  21. import ch.colabproject.colab.api.model.team.acl.Assignment;
  22. import ch.colabproject.colab.api.model.team.acl.HierarchicalPosition;
  23. import ch.colabproject.colab.api.model.team.acl.InvolvementLevel;
  24. import ch.colabproject.colab.api.model.token.*;
  25. import ch.colabproject.colab.api.model.user.HashMethod;
  26. import ch.colabproject.colab.api.model.user.LocalAccount;
  27. import ch.colabproject.colab.api.model.user.User;
  28. import ch.colabproject.colab.api.persistence.jpa.token.TokenDao;
  29. import ch.colabproject.colab.api.service.smtp.Message;
  30. import ch.colabproject.colab.api.service.smtp.Sendmail;
  31. import ch.colabproject.colab.api.setup.ColabConfiguration;
  32. import ch.colabproject.colab.generator.model.exceptions.HttpErrorMessage;
  33. import ch.colabproject.colab.generator.model.exceptions.MessageI18nKey;
  34. import org.slf4j.Logger;
  35. import org.slf4j.LoggerFactory;

  36. import javax.ejb.LocalBean;
  37. import javax.ejb.Stateless;
  38. import javax.inject.Inject;
  39. import javax.mail.MessagingException;
  40. import java.time.OffsetDateTime;
  41. import java.util.List;
  42. import java.util.Objects;

  43. /**
  44.  * Process tokens
  45.  *
  46.  * @author maxence
  47.  */
  48. @Stateless
  49. @LocalBean
  50. public class TokenManager {

  51.     /** logger */
  52.     private static final Logger logger = LoggerFactory.getLogger(TokenManager.class);

  53.     /**
  54.      * to create team member
  55.      */
  56.     @Inject
  57.     private TeamManager teamManager;

  58.     /**
  59.      * to create instanceMaker
  60.      */
  61.     @Inject
  62.     private InstanceMakerManager instanceMakerManager;

  63.     /**
  64.      * Assignment specific logic
  65.      */
  66.     @Inject
  67.     private AssignmentManager assignmentManager;

  68.     /**
  69.      * User and account specific logic
  70.      */
  71.     @Inject
  72.     private UserManager userManager;

  73.     /**
  74.      * Project specific logic
  75.      */
  76.     @Inject
  77.     private ProjectManager projectManager;

  78.     /**
  79.      * Card specific logic
  80.      */
  81.     @Inject
  82.     private CardManager cardManager;

  83.     /**
  84.      * To check access rights
  85.      */
  86.     @Inject
  87.     private SecurityManager securityManager;

  88.     /**
  89.      * Token persistence
  90.      */
  91.     @Inject
  92.     private TokenDao tokenDao;

  93.     /**
  94.      * Request context
  95.      */
  96.     @Inject
  97.     private RequestManager requestManager;

  98.     /**
  99.      * Persist the token
  100.      *
  101.      * @param token token to persist
  102.      */
  103.     private void persistToken(Token token) {
  104.         logger.debug("persist token {}", token);

  105.         // set something to respect notNull constraints
  106.         // otherwise persist will fail
  107.         // These values will be reset when the e-mail is sent.
  108.         if (token.getHashMethod() == null) {
  109.             token.setHashMethod(Helper.getDefaultHashMethod());
  110.         }
  111.         if (token.getHashedToken() == null) {
  112.             token.setHashedToken(new byte[0]);
  113.         }

  114.         tokenDao.persistToken(token);
  115.     }

  116.     /**
  117.      * Finalize initialization of the token and send it to the recipient.
  118.      * <p>
  119.      * As the plain token is not stored in the database, the token is regenerated in this method.
  120.      *
  121.      * @param token     the token to send
  122.      * @param recipient recipient email address
  123.      *
  124.      * @throws javax.mail.MessagingException if sending the message fails
  125.      */
  126.     public void sendTokenByEmail(EmailableToken token, String recipient) throws MessagingException {
  127.         logger.debug("Send token {} to {}", token, recipient);

  128.         String url = generateNewRandomPlainToken(token);

  129.         String body = token.getEmailBody(url);

  130.         // this log message contains sensitive information (body contains the plain-text token)
  131.         logger.trace("Send token {} to {} with body {}", token, recipient, body);
  132.         Sendmail.send(
  133.             Message.create()
  134.                 .from("noreply@" + ColabConfiguration.getSmtpDomain())
  135.                 .to(recipient)
  136.                 .subject(token.getSubject())
  137.                 .htmlBody(body)
  138.                 .build()
  139.         );
  140.     }

  141.     /**
  142.      * Generate a new random plain token.
  143.      * <p>
  144.      * For security reason, its value is not stored in database. The knowledge is split between
  145.      * hash data that are stored in database and a URL containing the plain token.
  146.      *
  147.      * @param token The token (its values will be changed)
  148.      *
  149.      * @return the URL which enables the token to be consumed
  150.      */
  151.     private String generateNewRandomPlainToken(TokenWithURL token) {
  152.         String plainToken = Helper.generateHexSalt(64);
  153.         HashMethod hashMethod = Helper.getDefaultHashMethod();
  154.         byte[] hashedToken = hashMethod.hash(plainToken, Token.SALT);

  155.         token.setHashMethod(hashMethod);
  156.         token.setHashedToken(hashedToken);

  157.         String baseUrl = requestManager.getBaseUrl();

  158.         return baseUrl + "/#/token/" + token.getId() + "/" + plainToken;
  159.     }

  160.     /**
  161.      * Consume the token
  162.      *
  163.      * @param id         the id of the token to consume
  164.      * @param plainToken the plain secret token as sent by e-mail
  165.      *
  166.      * @return the consumed token
  167.      *
  168.      * @throws HttpErrorMessage notFound if the token does not exist;<br>
  169.      *                          badRequest if token does not match;<br>
  170.      *                          authenticationRequired if token requires authentication but current
  171.      *                          user is not
  172.      */
  173.     public Token consume(Long id, String plainToken) {
  174.         logger.debug("Consume token #{}", id);

  175.         Token token = tokenDao.findToken(id);

  176.         if (token != null) {
  177.             if (token.isAuthenticationRequired() && !requestManager.isAuthenticated()) {
  178.                 logger.debug("Token requires an authenticated user");
  179.                 throw HttpErrorMessage.authenticationRequired();
  180.             } else {
  181.                 if (token.checkHash(plainToken)) {
  182.                     requestManager.sudo(() -> {
  183.                         boolean isConsumed = token.consume(this);

  184.                         if (isConsumed
  185.                             && token.getExpirationPolicy() == ExpirationPolicy.ONE_SHOT) {
  186.                             tokenDao.deleteToken(token);
  187.                         }
  188.                     });
  189.                     return token;
  190.                 } else {
  191.                     logger.debug("Provided plain-token does not match");
  192.                     throw HttpErrorMessage.badRequest();
  193.                 }
  194.             }

  195.         } else {
  196.             logger.debug("There is no token #{}", id);
  197.             throw HttpErrorMessage.notFound();
  198.         }
  199.     }

  200.     ////////////////////////////////////////////////////////////////////////////////////////////////
  201.     // Verify email address
  202.     ////////////////////////////////////////////////////////////////////////////////////////////////

  203.     /**
  204.      * Create or update a validation token.
  205.      * <p>
  206.      * If a validate token already exists for the given account, it will be updated so there is never
  207.      * more than one validation token per localAccount.
  208.      *
  209.      * @param account token owner
  210.      *
  211.      * @return a brand-new token or a refresh
  212.      */
  213.     private VerifyLocalAccountToken getOrCreateVerifyAccountToken(LocalAccount account) {
  214.         logger.debug("getOrCreate VerifyToken for {}", account);
  215.         VerifyLocalAccountToken token = tokenDao.findVerifyTokenByAccount(account);

  216.         if (token == null) {
  217.             logger.debug("no token, create one");
  218.             token = new VerifyLocalAccountToken();
  219.             token.setAuthenticationRequired(false);
  220.             token.setLocalAccount(account);
  221.             persistToken(token);
  222.         }
  223.         // token.setExpirationDate(OffsetDateTime.now().plus(1, ChronoUnit.WEEKS));
  224.         token.setExpirationDate(null);

  225.         return token;
  226.     }

  227.     /**
  228.      * Send a "Please verify your email address" message.
  229.      *
  230.      * @param account      account to verify
  231.      * @param failsOnError if false, silent SMTP error
  232.      *
  233.      * @throws HttpErrorMessage smtpError if there is an SMTP error AND failsOnError is set to true
  234.      *                          messageError if the message contains errors (e.g. malformed
  235.      *                          addresses)
  236.      */
  237.     public void requestEmailAddressVerification(LocalAccount account, boolean failsOnError) {
  238.         try {
  239.             VerifyLocalAccountToken token = this.getOrCreateVerifyAccountToken(account);
  240.             sendTokenByEmail(token, account.getEmail());
  241.         } catch (MessagingException ex) {
  242.             logger.error("Fails to send email address verification email", ex);
  243.             if (failsOnError) {
  244.                 throw HttpErrorMessage.smtpError();
  245.             }
  246.         }
  247.     }

  248.     /**
  249.      * Consume the local account verification token
  250.      *
  251.      * @param account the account related to the token
  252.      *
  253.      * @return true if the token can be consumed
  254.      */
  255.     public boolean consumeVerifyAccountToken(LocalAccount account) {
  256.         userManager.setLocalAccountAsVerified(account);

  257.         return true;
  258.     }

  259.     ////////////////////////////////////////////////////////////////////////////////////////////////
  260.     // RESET PASSWORD
  261.     ////////////////////////////////////////////////////////////////////////////////////////////////

  262.     /**
  263.      * get existing reset password token if it exists or create new one otherwise.
  264.      *
  265.      * @param account token owner
  266.      *
  267.      * @return the token to user
  268.      */
  269.     private ResetLocalAccountPasswordToken getOrCreateResetToken(LocalAccount account) {
  270.         logger.debug("getOrCreate Reset for {}", account);
  271.         ResetLocalAccountPasswordToken token = tokenDao.findResetTokenByAccount(account);

  272.         if (token == null) {
  273.             token = new ResetLocalAccountPasswordToken();
  274.             logger.debug("no token, create one");
  275.             token.setAuthenticationRequired(false);
  276.             token.setLocalAccount(account);
  277.             persistToken(token);
  278.         }
  279.         token.setExpirationDate(OffsetDateTime.now().plusHours(1));

  280.         return token;
  281.     }

  282.     /**
  283.      * Send a "Click here the reset your password" message.
  284.      *
  285.      * @param account      The account whose password is to be reset
  286.      * @param failsOnError if false, silent SMTP error
  287.      *
  288.      * @throws HttpErrorMessage smtpError if there is an SMTP error AND failsOnError is set to true
  289.      *                          messageError if the message contains errors (e.g. malformed
  290.      *                          addresses)
  291.      */
  292.     public void sendResetPasswordToken(LocalAccount account, boolean failsOnError) {
  293.         try {
  294.             logger.debug("Send reset password token to {}", account);
  295.             ResetLocalAccountPasswordToken token = this.getOrCreateResetToken(account);
  296.             sendTokenByEmail(token, account.getEmail());
  297.         } catch (MessagingException ex) {
  298.             logger.error("Failed to send password reset email", ex);
  299.             if (failsOnError) {
  300.                 throw HttpErrorMessage.smtpError();
  301.             }
  302.         }
  303.     }

  304.     /**
  305.      * Consume the given reset password login.
  306.      *
  307.      * @param account the account related to the token
  308.      *
  309.      * @return true if the token can be consumed
  310.      */
  311.     public boolean consumeResetPasswordToken(LocalAccount account) {
  312.         requestManager.login(account);

  313.         return true;
  314.     }

  315.     ////////////////////////////////////////////////////////////////////////////////////////////////
  316.     // Invite a new team member
  317.     ////////////////////////////////////////////////////////////////////////////////////////////////

  318.     /**
  319.      * Send invitation to join the project team to the recipient.
  320.      *
  321.      * @param project   the project to join
  322.      * @param recipient email address to send invitation to
  323.      *
  324.      * @return the pending teamMember of null if none was sent
  325.      */
  326.     public TeamMember sendMembershipInvitation(Project project, String recipient) {
  327.         User currentUser = securityManager.assertAndGetCurrentUser();

  328.         InvitationToken token = tokenDao.findInvitationByProjectAndRecipient(project, recipient);
  329.         if (token == null) {
  330.             // create a member and link it to the project, but do not link it to any user
  331.             // this link will be set during token consumption
  332.             TeamMember newMember = teamManager.addMember(project, null,
  333.                 HierarchicalPosition.INTERNAL);
  334.             token = new InvitationToken();

  335.             token.setTeamMember(newMember);
  336.             // never expire
  337.             token.setExpirationDate(null);
  338.             token.setAuthenticationRequired(Boolean.TRUE);
  339.             token.setRecipient(recipient);

  340.             newMember.setDisplayName(recipient);

  341.             persistToken(token);
  342.         }

  343.         token.setSender(currentUser.getDisplayName());

  344.         try {
  345.             sendTokenByEmail(token, recipient);
  346.         } catch (MessagingException ex) {
  347.             logger.error("Failed to send membership invitation email", ex);
  348.             throw HttpErrorMessage.smtpError();
  349.         }

  350.         return token.getTeamMember();
  351.     }

  352.     /**
  353.      * Delete all invitations linked to the team member
  354.      *
  355.      * @param teamMember the team member for which we delete all invitations
  356.      */
  357.     public void deleteInvitationsByTeamMember(TeamMember teamMember) {
  358.         List<InvitationToken> invitations = tokenDao.findInvitationByTeamMember(teamMember);
  359.         invitations.forEach(token -> tokenDao.deleteToken(token));
  360.     }

  361.     /**
  362.      * Consume the invitation token
  363.      *
  364.      * @param teamMember the team member related to the token
  365.      *
  366.      * @return true if the token can be consumed
  367.      */
  368.     public boolean consumeInvitationToken(TeamMember teamMember) {
  369.         User user = requestManager.getCurrentUser();

  370.         if (user == null) {
  371.             throw HttpErrorMessage.authenticationRequired();
  372.         }

  373.         Project project = teamMember.getProject();

  374.         TeamMember existingTeamMember = teamManager.findMemberByProjectAndUser(project, user);
  375.         if (existingTeamMember != null) {
  376.             throw HttpErrorMessage
  377.                 .tokenProcessingFailure(MessageI18nKey.USER_IS_ALREADY_A_TEAM_MEMBER);
  378.         }

  379.         teamMember.setUser(user);
  380.         teamMember.setDisplayName(null);

  381.         return true;
  382.     }

  383.     ////////////////////////////////////////////////////////////////////////////////////////////////
  384.     // Share a model to someone
  385.     ////////////////////////////////////////////////////////////////////////////////////////////////

  386.     /**
  387.      * Send a model sharing token to register the project as a model to use
  388.      * <p>
  389.      * If the token does not exist yet, create it and link to it a new pending instance maker.
  390.      *
  391.      * @param model     the id of the model
  392.      * @param recipient the address to send the sharing token to
  393.      *
  394.      * @return the pending instance maker
  395.      */
  396.     public InstanceMaker sendModelSharingToken(Project model, String recipient) {
  397.         User currentUser = securityManager.assertAndGetCurrentUser();

  398.         ModelSharingToken token = tokenDao.findModelSharingByProjectAndRecipient(model, recipient);

  399.         if (token == null) {
  400.             // create an instance maker and link it to the project, but do not link it to any user
  401.             // this link will be set during token consumption
  402.             InstanceMaker newInstanceMaker = instanceMakerManager.addAndPersistInstanceMaker(model, null);


  403.             token = new ModelSharingToken();

  404.             token.setInstanceMaker(newInstanceMaker);
  405.             token.setExpirationDate(null);
  406.             token.setAuthenticationRequired(Boolean.TRUE);
  407.             token.setRecipient(recipient);

  408.             newInstanceMaker.setDisplayName(recipient);

  409.             persistToken(token);
  410.         }

  411.         token.setSender(currentUser.getDisplayName());

  412.         try {
  413.             sendTokenByEmail(token, recipient);
  414.         } catch (MessagingException ex) {
  415.             logger.error("Failed to send model sharing email", ex);
  416.             throw HttpErrorMessage.smtpError();
  417.         }

  418.         return token.getInstanceMaker();
  419.     }

  420.     /**
  421.      * Delete all model sharing tokens linked to the instance maker
  422.      *
  423.      * @param instanceMaker the instance maker for which we delete all invitations
  424.      */
  425.     public void deleteModelSharingTokenByInstanceMaker(InstanceMaker instanceMaker) {
  426.         List<ModelSharingToken> tokens = tokenDao.findModelSharingByInstanceMaker(instanceMaker);
  427.         tokens.forEach(token -> tokenDao.deleteToken(token));
  428.     }

  429.     /**
  430.      * Consume the model sharing token
  431.      *
  432.      * @param instanceMaker the instance maker related to the token
  433.      *
  434.      * @return true if the token can be consumed
  435.      */
  436.     public boolean consumeModelSharingToken(InstanceMaker instanceMaker) {
  437.         User user = requestManager.getCurrentUser();

  438.         if (user == null) {
  439.             throw HttpErrorMessage.authenticationRequired();
  440.         }

  441.         Project model = instanceMaker.getProject();

  442.         InstanceMaker existingInstanceMaker = instanceMakerManager
  443.             .findInstanceMakerByProjectAndUser(model, user);
  444.         if (existingInstanceMaker != null) {
  445.             throw HttpErrorMessage.tokenProcessingFailure(
  446.                 MessageI18nKey.CURRENT_USER_CAN_ALREADY_USE_MODEL);
  447.         }

  448.         instanceMaker.setUser(user);
  449.         instanceMaker.setDisplayName(null);

  450.         return true;
  451.     }

  452.     ////////////////////////////////////////////////////////////////////////////////////////////////
  453.     // Share a project card to enable people (not defined who) to edit it
  454.     ////////////////////////////////////////////////////////////////////////////////////////////////

  455.     /**
  456.      * Create a token to share the project.
  457.      *
  458.      * @param project The project that will become visible (mandatory)
  459.      * @param card    The card that will become editable (optional)
  460.      *
  461.      * @return the URL to use to consume the token
  462.      */
  463.     public String generateSharingLinkToken(Project project, Card card) {
  464.         if (project == null) {
  465.             throw HttpErrorMessage.badRequest();
  466.         }

  467.         boolean isCurrentUserInternalToProject = securityManager.isCurrentUserInternalToProject(project);

  468.         if (!isCurrentUserInternalToProject) {
  469.             throw HttpErrorMessage.forbidden();
  470.         }

  471.         if (card != null) {
  472.             boolean canCurrentUserEditCard = securityManager.hasReadWriteAccess(card);

  473.             if (!canCurrentUserEditCard) {
  474.                 throw HttpErrorMessage.forbidden();
  475.             }
  476.         }

  477.         // we never re-use an existing token
  478.         // because when we generate the URL, it changes the hash data and so invalidate existing token.

  479.         SharingLinkToken token = new SharingLinkToken();
  480.         token.setProjectId(project.getId());
  481.         token.setCardId(card != null ? card.getId() : null);
  482.         token.setAuthenticationRequired(Boolean.TRUE);
  483.         token.setExpirationDate(null);
  484.         persistToken(token);

  485.         return generateNewRandomPlainToken(token);
  486.     }

  487.     /**
  488.      * Delete all sharing link tokens for the given project.
  489.      *
  490.      * @param project the project
  491.      */
  492.     public void deleteSharingLinkTokensByProject(Project project) {
  493.         List<SharingLinkToken> sharingLinkTokens = tokenDao.findSharingLinkByProject(project);
  494.         sharingLinkTokens.forEach(token -> tokenDao.deleteToken(token));
  495.     }

  496.     /**
  497.      * Delete all sharing link tokens for the given card.
  498.      *
  499.      * @param card the card
  500.      */
  501.     public void deleteSharingLinkTokensByCard(Card card) {
  502.         List<SharingLinkToken> sharingLinkTokens = tokenDao.findSharingLinkByCard(card);
  503.         sharingLinkTokens.forEach(token -> tokenDao.deleteToken(token));
  504.     }

  505.     /**
  506.      * Consume the token to ensure that the user can view the project and edit the card (if set)
  507.      *
  508.      * @param projectId The id of the project that will become visible (mandatory)
  509.      * @param cardId    The id of the card that will become editable (optional)
  510.      *
  511.      * @return true if it could happen
  512.      */
  513.     public boolean consumeSharingLinkToken(Long projectId, Long cardId) {
  514.         User user = requestManager.getCurrentUser();

  515.         Project project = projectManager.assertAndGetProject(projectId);
  516.         Card card = cardManager.assertAndGetCard(cardId);

  517.         if (user == null) {
  518.             throw HttpErrorMessage.authenticationRequired();
  519.         }

  520.         TeamMember teamMember = teamManager.findMemberByProjectAndUser(project, user);
  521.         if (teamMember == null) {
  522.             teamMember = teamManager.addMember(project, user, HierarchicalPosition.GUEST);
  523.         }

  524.         if (card != null) {
  525.             if (!Objects.equals(card.getProject(), project)) {
  526.                 throw HttpErrorMessage.dataError(MessageI18nKey.DATA_INTEGRITY_FAILURE);
  527.             }

  528.             List<Assignment> assignments =
  529.                     assignmentManager.getAssignmentsForCardAndTeamMember(card, teamMember);

  530.             if (assignments.isEmpty()) {
  531.                 assignmentManager.setAssignment(card.getId(), teamMember.getId(),
  532.                         InvolvementLevel.SUPPORT);
  533.             }
  534.         }

  535.         return true;
  536.     }

  537.     ////////////////////////////////////////////////////////////////////////////////////////////////
  538.     // for each token
  539.     ////////////////////////////////////////////////////////////////////////////////////////////////

  540. //    /**
  541. //     * Delete all invitations linked to the project
  542. //     *
  543. //     * @param project the project for which we delete all tokens
  544. //     */
  545. //    public void deleteTokensByProject(Project project) {
  546. //        List<Token> tokens = tokenDao.findTokensByProject(project);
  547. //        tokens.stream().forEach(token -> tokenDao.deleteToken(token));
  548. //    }

  549.     /**
  550.      * Fetch token with given id from DAO. If it's outdated, it will be destroyed and null will be
  551.      * returned
  552.      *
  553.      * @param id id of the token
  554.      *
  555.      * @return token if it exists and is not outdated, null otherwise
  556.      */
  557.     public Token getNotExpiredToken(Long id) {
  558.         Token token = tokenDao.findToken(id);

  559.         if (token != null && token.isOutdated()) {
  560.             requestManager.sudo(() -> tokenDao.deleteToken(token));
  561.             return null;
  562.         }

  563.         return token;
  564.     }
  565. }