<%#
 Copyright 2013-2023 the original author or authors from the JHipster project.

 This file is part of the JHipster project, see https://www.jhipster.tech/
 for more information.

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

      https://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
-%>
package <%= packageName %>.security;

import <%= packageName %>.domain.PersistentToken;
import <%= packageName %>.repository.PersistentTokenRepository;
import <%= packageName %>.repository.UserRepository;

<%_ if (databaseTypeCassandra) { _%>
import com.datastax.oss.driver.api.core.DriverException;
<%_ } _%>

import tech.jhipster.config.JHipsterProperties;
import tech.jhipster.security.PersistentTokenCache;
import tech.jhipster.security.RandomUtil;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j || databaseTypeCouchbase) { _%>
import org.springframework.dao.DataAccessException;
<%_ } _%>
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.rememberme.*;
import org.springframework.stereotype.Service;

import <%= javaVersion === '17' ? 'jakarta' : 'javax'%>.servlet.http.HttpServletRequest;
import <%= javaVersion === '17' ? 'jakarta' : 'javax'%>.servlet.http.HttpServletResponse;
import java.io.Serializable;
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j || databaseTypeCouchbase) { _%>
import java.time.LocalDate;
<%_ } else if (databaseTypeCassandra) { _%>
import java.time.Instant;
import java.time.temporal.ChronoUnit;
<%_ } _%>
import java.util.*;

/**
 * Custom implementation of Spring Security's RememberMeServices.
 * <p>
 * Persistent tokens are used by Spring Security to automatically log in users.
 * <p>
 * This is a specific implementation of Spring Security's remember-me authentication, but it is much
 * more powerful than the standard implementations:
 * <ul>
 * <li>It allows a user to see the list of his currently opened sessions, and invalidate them</li>
 * <li>It stores more information, such as the IP address and the user agent, for audit purposes<li>
 * <li>When a user logs out, only his current session is invalidated, and not all of his sessions</li>
 * </ul>
 * <p>
 * Please note that it allows the use of the same token for 5 seconds, and this value stored in a specific
 * cache during that period. This is to allow concurrent requests from the same user: otherwise, two
 * requests being sent at the same time could invalidate each other's token.
 * <p>
 * This is inspired by:
 * <ul>
 * <li><a href="https://github.com/blog/1661-modeling-your-app-s-user-session">GitHub's "Modeling your App's User Session"</a></li>
 * </ul>
 * <p>
 * The main algorithm comes from Spring Security's {@code PersistentTokenBasedRememberMeServices}, but this class
 * couldn't be cleanly extended.
 */
@Service
public class PersistentTokenRememberMeServices extends
    AbstractRememberMeServices {

    private final Logger log = LoggerFactory.getLogger(PersistentTokenRememberMeServices.class);

    // Token is valid for one month
    private static final int TOKEN_VALIDITY_DAYS = 31;

    private static final int TOKEN_VALIDITY_SECONDS = 60 * 60 * 24 * TOKEN_VALIDITY_DAYS;

    private static final long UPGRADED_TOKEN_VALIDITY_MILLIS = 5000l;

    private final PersistentTokenCache<UpgradedRememberMeToken> upgradedTokenCache;

    private final PersistentTokenRepository persistentTokenRepository;

    private final UserRepository userRepository;

    public PersistentTokenRememberMeServices(JHipsterProperties jHipsterProperties,
            org.springframework.security.core.userdetails.UserDetailsService userDetailsService,
            PersistentTokenRepository persistentTokenRepository, UserRepository userRepository) {

        super(jHipsterProperties.getSecurity().getRememberMe().getKey(), userDetailsService);
        this.persistentTokenRepository = persistentTokenRepository;
        this.userRepository = userRepository;
        upgradedTokenCache = new PersistentTokenCache<>(UPGRADED_TOKEN_VALIDITY_MILLIS);
    }

    @Override
    protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
        HttpServletResponse response) {

        synchronized (this) { // prevent 2 authentication requests from the same user in parallel
            String login = null;
            UpgradedRememberMeToken upgradedToken = upgradedTokenCache.get(cookieTokens[0]);
            if (upgradedToken != null) {
                login = upgradedToken.getUserLoginIfValid(cookieTokens);
                log.debug("Detected previously upgraded login token for user '{}'", login);
            }

            if (login == null) {
                PersistentToken token = getPersistentToken(cookieTokens);
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j) { _%>
                login = token.getUser().getLogin();
<%_ } else { _%>
                login = token.getLogin();
<%_ } _%>

                // Token also matches, so login is valid. Update the token value, keeping the *same* series number.
                log.debug("Refreshing persistent login token for user '{}', series '{}'", login, token.getSeries());
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j || databaseTypeCouchbase) { _%>
                token.setTokenDate(LocalDate.now());
<%_ } else if (databaseTypeCassandra) { _%>
                token.setTokenDate(Instant.now());
<%_ } _%>
                token.setTokenValue(RandomUtil.generateRandomAlphanumericString());
                token.setIpAddress(request.getRemoteAddr());
                token.setUserAgent(request.getHeader("User-Agent"));
                try {
                    persistentTokenRepository.save<% if (databaseTypeSql) { %>AndFlush<% } %>(token);
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j || databaseTypeCouchbase) { _%>
                } catch (DataAccessException e) {
<%_ } else if (databaseTypeCassandra) { _%>
                } catch (DriverException e) {
<%_ } _%>
                    log.error("Failed to update token: ", e);
                    throw new RememberMeAuthenticationException("Autologin failed due to data access problem", e);
                }
                addCookie(token, request, response);
                upgradedTokenCache.put(cookieTokens[0], new UpgradedRememberMeToken(cookieTokens, login));
            }
            return getUserDetailsService().loadUserByUsername(login);
        }
    }

    @Override
    protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication
        successfulAuthentication) {

        String login = successfulAuthentication.getName();

        log.debug("Creating new persistent login for user {}", login);
        PersistentToken token = userRepository.findOneByLogin(login).map(u -> {
            PersistentToken t = new PersistentToken();
            t.setSeries(RandomUtil.generateRandomAlphanumericString());
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j) { _%>
            t.setUser(u);
<%_ } else { _%>
            t.setLogin(login);
<%_ } _%>
<%_ if (databaseTypeCassandra) { _%>
            t.setUserId(u.getId());
<%_ } _%>
            t.setTokenValue(RandomUtil.generateRandomAlphanumericString());
            t.setTokenDate(<% if (databaseTypeCassandra) { %>Instant.now()<% } else { %>LocalDate.now()<% } %>);
            t.setIpAddress(request.getRemoteAddr());
            t.setUserAgent(request.getHeader("User-Agent"));
            return t;
        }).orElseThrow(() -> new UsernameNotFoundException("User " + login + " was not found in the database"));
        try {
            persistentTokenRepository.save<% if (databaseTypeSql) { %>AndFlush<% } %>(token);
            addCookie(token, request, response);
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j || databaseTypeCouchbase) { _%>
        } catch (DataAccessException e) {
<%_ } else if (databaseTypeCassandra) { _%>
        } catch (DriverException e) {
<%_ } _%>
            log.error("Failed to save persistent token ", e);
        }
    }

    /**
     * When logout occurs, only invalidate the current token, and not all user sessions.
     * <p>
     * The standard Spring Security implementations are too basic: they invalidate all tokens for the
     * current user, so when he logs out from one browser, all his other sessions are destroyed.
     *
     * @param request the request.
     * @param response the response.
     * @param authentication the authentication.
     */
    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        String rememberMeCookie = extractRememberMeCookie(request);
        if (rememberMeCookie != null && rememberMeCookie.length() != 0) {
            try {
                String[] cookieTokens = decodeCookie(rememberMeCookie);
                PersistentToken token = getPersistentToken(cookieTokens);
<%_ if (databaseTypeSql) { _%>
                persistentTokenRepository.deleteById(token.getSeries());
<%_ } _%>
<%_ if (databaseTypeMongodb || databaseTypeCouchbase) { _%>
                persistentTokenRepository.delete(token);
<%_ } _%>
            } catch (InvalidCookieException ice) {
                log.info("Invalid cookie, no persistent token could be deleted", ice);
            } catch (RememberMeAuthenticationException rmae) {
                log.debug("No persistent token found, so no token could be deleted", rmae);
            }
        }
        super.logout(request, response, authentication);
    }

    /**
     * Validate the token and return it.
     */
    private PersistentToken getPersistentToken(String[] cookieTokens) {
        if (cookieTokens.length != 2) {
            throw new InvalidCookieException("Cookie token did not contain " + 2 +
                " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
        }
        String presentedSeries = cookieTokens[0];
        String presentedToken = cookieTokens[1];
<%_ if (databaseTypeCouchbase) { _%>
        Optional<PersistentToken> optionalToken = persistentTokenRepository.findBySeries(presentedSeries);
<%_ } else { _%>
        Optional<PersistentToken> optionalToken = persistentTokenRepository.findById(presentedSeries);
<%_ } _%>
        if (!optionalToken.isPresent()) {
            // No series match, so we can't authenticate using this cookie
            throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
        }
        PersistentToken token = optionalToken.orElseThrow();
        // We have a match for this user/series combination
        log.info("presentedToken={} / tokenValue={}", presentedToken, token.getTokenValue());
        if (!presentedToken.equals(token.getTokenValue())) {
            // Token doesn't match series value. Delete this session and throw an exception.
<%_ if (databaseTypeSql) { _%>
            persistentTokenRepository.deleteById(token.getSeries());
<%_ } _%>
<%_ if (databaseTypeMongodb || databaseTypeCouchbase) { _%>
            persistentTokenRepository.delete(token);
<%_ } _%>
            throw new CookieTheftException("Invalid remember-me token (Series/token) mismatch. Implies previous " +
                "cookie theft attack.");
        }
<%_ if (databaseTypeSql || databaseTypeMongodb || databaseTypeNeo4j || databaseTypeCouchbase) { _%>
        if (token.getTokenDate().plusDays(TOKEN_VALIDITY_DAYS).isBefore(LocalDate.now())) {
<%_ } _%>
<%_ if (databaseTypeCassandra) { _%>
        if (token.getTokenDate().plus(TOKEN_VALIDITY_DAYS, ChronoUnit.DAYS).isBefore(Instant.now())) {
<%_ } _%>
<%_ if (databaseTypeSql) { _%>
            persistentTokenRepository.deleteById(token.getSeries());
<%_ } _%>
<%_ if (databaseTypeMongodb || databaseTypeCouchbase) { _%>
            persistentTokenRepository.delete(token);
<%_ } _%>
            throw new RememberMeAuthenticationException("Remember-me login has expired");
        }
        return token;
    }

    private void addCookie(PersistentToken token, HttpServletRequest request, HttpServletResponse response) {
        setCookie(
            new String[]{token.getSeries(), token.getTokenValue()},
            TOKEN_VALIDITY_SECONDS, request, response);
    }

    private static class UpgradedRememberMeToken implements Serializable {

        private static final long serialVersionUID = 1L;

        private final String[] upgradedToken;

        private final String userLogin;

        UpgradedRememberMeToken(String[] upgradedToken, String userLogin) {
            this.upgradedToken = upgradedToken;
            this.userLogin = userLogin;
        }

        String getUserLoginIfValid(String[] currentToken) {
            if (currentToken[0].equals(this.upgradedToken[0]) &&
                    currentToken[1].equals(this.upgradedToken[1])) {
                return this.userLogin;
            }
            return null;
        }
    }
}
