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