SessionManager.java

/*
 * The coLAB project
 * Copyright (C) 2021-2023 AlbaSim, MEI, HEIG-VD, HES-SO
 *
 * Licensed under the MIT License
 */
package ch.colabproject.colab.api.security;

import ch.colabproject.colab.api.Helper;
import ch.colabproject.colab.api.controller.RequestManager;
import ch.colabproject.colab.api.controller.WebsocketManager;
import ch.colabproject.colab.api.model.user.Account;
import ch.colabproject.colab.api.model.user.HttpSession;
import ch.colabproject.colab.api.model.user.InternalHashMethod;
import ch.colabproject.colab.api.model.user.LocalAccount;
import ch.colabproject.colab.api.model.user.User;
import ch.colabproject.colab.api.persistence.jpa.user.HttpSessionDao;
import ch.colabproject.colab.api.persistence.jpa.user.UserDao;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.time.OffsetDateTime;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.cache.Cache;
import javax.cache.processor.MutableEntry;
import javax.ejb.LocalBean;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.cp.lock.FencedLock;
import com.hazelcast.flakeidgen.FlakeIdGenerator;
import com.hazelcast.map.IMap;

/**
 * Bean to manage HTTP sessions
 *
 * @author maxence
 */
@Stateless
@LocalBean
public class SessionManager {

    /** logger */
    private static final Logger logger = LoggerFactory.getLogger(SessionManager.class);

    /** hazelcast instance */
    @Inject
    private HazelcastInstance hzInstance;

    /** User persistence handling */
    @Inject
    private UserDao userDao;

    /** Http session persistence handling */
    @Inject
    private HttpSessionDao httpSessionDao;

    /**
     * Websocket business logic
     */
    @Inject
    private WebsocketManager websocketManager;

    /** request manager */
    @Inject
    private RequestManager requestManager;

    /** cache of failed authentication (key = account id) */
    @Inject
    private Cache<Long, AuthenticationFailure> authenticationFailureCache;

    /**
     * get user activity date cache. Map user id with activity date
     */
    private IMap<Long, OffsetDateTime> getUserActivityCache() {
        return hzInstance.getMap("USER_ACTIVITY_CACHE");
    }

    /**
     * get http session activity date cache. Map http session id with activity date
     */
    private IMap<Long, OffsetDateTime> getHttpSessionActivityCache() {
        return hzInstance.getMap("HTTP_SESSION_ACTIVITY_CACHE");
    }

    /**
     * Get a persisted session.
     *
     * @param sessionId session id
     * @param secret    the session secret
     *
     * @return a session if it exists or null
     */
    public HttpSession getAndValidate(Long sessionId, String secret) {
        HttpSession httpSession = httpSessionDao.findHttpSession(sessionId);

        if (httpSession != null) {
            try {
                // hash the secret sent by the client
                byte[] hash = InternalHashMethod.SHA_512.hash(secret);
                httpSession.getSessionSecret();
                if (Helper.constantTimeArrayEquals(hash, httpSession.getSessionSecret())) {
                    return httpSession;
                } else {
                    logger.error("Cookie secret does not match");
                }
            } catch (NoSuchAlgorithmException ex) {
                logger.error("SHA_512 NOT FOUND, THIS IS NOT GOOD; PLEASE INVESTIGATE");
            }
        }
        return null;
    }

    /**
     * Create and persist a new HTTP Session bound.
     *
     * @param account   the account the session is bound to
     * @param userAgent client user-agent
     *
     * @return brand new persisted HTTPsession
     */
    public HttpSession createHttpSession(Account account, String userAgent) {
        logger.debug("Creater new HttpSession for {}", account);
        FlakeIdGenerator idGenerator = hzInstance.getFlakeIdGenerator("HTTP_SESSION_ID_GENERATOR");

        String rawSecret = Helper.generateHexSalt(64) + "-" + idGenerator.newId();
        byte[] secret;

        try {
            secret = InternalHashMethod.SHA_512.hash(rawSecret);
        } catch (NoSuchAlgorithmException ex) {
            secret = rawSecret.getBytes(StandardCharsets.UTF_8);
            logger.error("SHA_512 NOT FOUND, THIS IS NOT GOOD; PLEASE INVESTIGATE");
        }

        HttpSession httpSession = new HttpSession();

        httpSession.setRawSessionSecret(rawSecret);
        httpSession.setSessionSecret(secret);

        httpSession.setAccount(account);
        account.getHttpSessions().add(httpSession);
        httpSession.setLastSeen(OffsetDateTime.now());

        if (userAgent != null) {
            httpSession.setUserAgent(userAgent);
        } else {
            httpSession.setUserAgent("");
        }

        return httpSessionDao.persistHttpSession(httpSession);
    }

    /**
     * Remove httpSession
     *
     * @param session the httpSession to delete
     */
    public void deleteHttpSession(HttpSession session) {
        Account account = session.getAccount();

        if (account != null) {
            account.getHttpSessions().remove(session);
        }

        websocketManager.signoutAndUnsubscribeFromAll(session);

        httpSessionDao.deleteHttpSession(session);
    }

    /**
     * keep trace of failed authentication attempt
     *
     * @param account the local account for which authentication failed
     *
     * @return the number of failed attempts in a row
     */
    public Long authenticationFailure(LocalAccount account) {
        return this.authenticationFailureCache.invoke(account.getId(),
            (MutableEntry<Long, AuthenticationFailure> entry, Object... arguments) -> {
                if (entry.exists()) {
                    AuthenticationFailure value = entry.getValue();
                    value.inc();
                    entry.setValue(value);
                    return entry.getValue().getCounter();
                } else {
                    entry.setValue(new AuthenticationFailure());
                    return 1L;
                }
            });
    }

    /**
     * clear failed attempts for given account
     *
     * @param account the account to clear attempts for
     */
    public void resetAuthenticationAttemptHistory(LocalAccount account) {
        this.authenticationFailureCache.remove(account.getId());
    }

    /**
     * Get history of failed authentication attempts for an account
     *
     * @param account account
     *
     * @return authentication failure history or null
     */
    public AuthenticationFailure getAuthenticationAttempt(LocalAccount account) {
        return this.authenticationFailureCache.get(account.getId());
    }

    /**
     * Touch activity date for currentAccount
     */
    public void touchUserActivityDate() {
        HttpSession httpSession = requestManager.getHttpSession();
        User user = requestManager.getCurrentUser();
        OffsetDateTime now = OffsetDateTime.now();
        logger.trace("Touch Activity ({}, {}) => {}", httpSession, user, now);

        if (httpSession != null) {
            getHttpSessionActivityCache().set(httpSession.getId(), now);
        }
        if (user != null && user.getId() != null) {
            user.setActivityDate(now);
            getUserActivityCache().set(user.getId(), now);
        }
    }

    /**
     * Get effective activity date for account
     *
     * @param user the account
     *
     * @return effective activity date
     */
    // Note : seems to be unused
    public OffsetDateTime getActivityDate(User user) {
        if (user != null) {
            if (user.getId() != null) {
                OffsetDateTime date = getUserActivityCache().get(user.getId());
                if (date != null) {
                    return date;
                }
            }
            return user.getActivityDate();
        }
        return null;
    }

    /**
     * Write in-cache activity-date to database
     */
    @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
    public void writeActivityDatesToDatabase() {
        logger.trace("Write Activity Date to DB");
        FencedLock lock = hzInstance.getCPSubsystem().getLock("CleanExpiredHttpSession");
        if (lock.tryLock()) {
            try {
                requestManager.sudo(() -> {
                    // prevent updating tracking data (updated by and at)
                    requestManager.setDoNotTrackChange(true);
                    IMap<Long, OffsetDateTime> userActivityCache = getUserActivityCache();
                    Iterator<Map.Entry<Long, OffsetDateTime>> iterator = userActivityCache
                        .iterator();
                    while (iterator.hasNext()) {
                        Map.Entry<Long, OffsetDateTime> next = iterator.next();
                        iterator.remove();
                        if (next != null) {
                            Long userId = next.getKey();
                            if (userId != null) {
                                User user = userDao.findUser(userId);
                                OffsetDateTime date = next.getValue();
                                if (user != null) {
                                    logger.trace("Update User LastSeenAt: {}", date);
                                    user.setLastSeenAt(date);
                                }
                            }
                        }
                    }

                    IMap<Long, OffsetDateTime> sessionActivityCache = getHttpSessionActivityCache();
                    iterator = sessionActivityCache.iterator();
                    while (iterator.hasNext()) {
                        Map.Entry<Long, OffsetDateTime> next = iterator.next();
                        iterator.remove();
                        if (next != null) {
                            Long id = next.getKey();
                            if (id != null) {
                                HttpSession session = httpSessionDao.findHttpSession(id);
                                if (session != null) {
                                    OffsetDateTime date = next.getValue();
                                    logger.trace("Update HTTP session LastSeen: {}", date);
                                    session.setLastSeen(date);
                                }
                            }
                        }
                    }
                });
            } finally {
                lock.unlock();
            }
        }
    }

    /**
     * Clean database. Remove expired HttpSession.
     */
    @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
    public void clearExpiredHttpSessions() {
        logger.trace("Clear expired HTTP session");
        requestManager.sudo(() -> {
            FencedLock lock = hzInstance.getCPSubsystem().getLock("CleanExpiredHttpSession");
            if (lock.tryLock()) {
                try {
                    logger.trace("Got the lock, let's clear");
                    List<HttpSession> list = httpSessionDao.findExpiredHttpSessions();
                    logger.trace("List of expired http session: {}", list);
                    IMap<Long, OffsetDateTime> cache = getHttpSessionActivityCache();
                    for (HttpSession session : list) {
                        if (!cache.containsKey(session.getId())) {
                            logger.trace("Delete the http session {}", session);
                            deleteHttpSession(session);
                        } else {
                            logger.trace("Seems http Session just woke up: {}", session);
                        }
                    }
                } finally {
                    lock.unlock();
                }
            } else {
                logger.trace("Did not get the lock");
            }
        });
    }
}