TokenService.java

package api.services;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.stereotype.Service;

import api.dtos.AuthenticationDto;
import api.entities.RefreshToken;
import api.entities.User;
import api.exceptions.RefreshTokenException;
import api.repositories.RefreshTokenRepository;

/**
 * {@link TokenService}.
 */
@Slf4j
@Service
public class TokenService {
    @Autowired
    private Clock clock;

    @Autowired
    private RefreshTokenRepository refreshTokenRepository;

    @SuppressWarnings("checkstyle:LineLength")
    @Value("${jwt.token.secret:XrLHWXPiznJfz3jvJF9ZJkIzvgC0RAF64dOO8bqSxJ2LStAOUAIO85gWg7tcFlfLL9c6q40UCRKMlwnyM5OQOg==}")
    private String secret;

    @Value("${jwt.access-token.expires-minutes:30}")
    private int accessTokenExpiresMinutes;

    @Value("${jwt.refresh-token.expires-days:7}")
    private int refreshTokenExpiresDays;

    private JwtParser parser;

    /**
     * Initialize parser.
     */
    @PostConstruct
    public void init() {
        parser = Jwts.parser().clock(() -> Date.from(Instant.now(clock)))
            .verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret))).build();
    }

    /**
     * Generate new JWT token.
     *
     * @param username Username
     * @return {@link AuthenticationDto}
     */
    @Transactional
    public AuthenticationDto generateAccessToken(String username) {
        Instant now = Instant.now(clock);
        Date issuedAt = Date.from(now);
        Date expireDate = Date.from(now.plus(accessTokenExpiresMinutes, ChronoUnit.MINUTES));

        String accessToken = Jwts.builder().subject(username).issuedAt(issuedAt).expiration(expireDate)
            .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret))).compact();
        return new AuthenticationDto(accessToken, "Bearer", expireDate);
    }

    /**
     * Get or generate a refresh token for a user.
     *
     * @param user User info
     * @return {@link AuthenticationDto}
     */
    @Transactional
    public RefreshToken generateRefreshToken(User user) {
        return refreshTokenRepository.findByUser(user).map(this::regenerateRefreshToken).orElseGet(() -> {
            String rawToken = UUID.randomUUID().toString();
            String hashed = hash(rawToken);

            RefreshToken newToken = RefreshToken.builder().user(user)
                .expires(Instant.now(clock).plus(refreshTokenExpiresDays, ChronoUnit.DAYS)).token(hashed)
                .rawToken(rawToken).build();

            return refreshTokenRepository.save(newToken);
        });
    }

    /**
     * Validates a refresh token.
     *
     * @param refreshToken Refresh token value
     * @return {@link String} username
     */
    @Transactional
    public RefreshToken validateAndRegenerate(String refreshToken) {
        RefreshToken token =
            refreshTokenRepository.findByToken(hash(refreshToken)).orElseThrow(() -> new RefreshTokenException());

        if (token.getExpires().isBefore(Instant.now(clock))) {
            refreshTokenRepository.delete(token);
            throw new RefreshTokenException();
        }

        RefreshToken regen = regenerateRefreshToken(token);
        Hibernate.initialize(regen.getUser());

        return regen;
    }

    /**
     * Invalidates a refresh token.
     *
     * @param refreshToken Refresh token value
     */
    @Transactional
    public void invalidate(String refreshToken) {
        refreshTokenRepository.findByToken(hash(refreshToken)).ifPresent(refreshTokenRepository::delete);
    }

    /**
     * Get username from JWT token.
     *
     * @param token JWT token
     * @return Username
     */
    public String getUsernameByToken(String token) {
        try {
            return parser.parseSignedClaims(token).getPayload().getSubject();
        } catch (ExpiredJwtException e) {
            throw new AuthenticationCredentialsNotFoundException("Access token is expired");
        } catch (Exception e) {
            throw new AuthenticationCredentialsNotFoundException("Access token is invalid");
        }
    }

    @Transactional
    private RefreshToken regenerateRefreshToken(RefreshToken token) {
        String rawToken = UUID.randomUUID().toString();
        String hashedToken = hash(rawToken);

        token.setExpires(Instant.now(clock).plus(refreshTokenExpiresDays, ChronoUnit.DAYS));
        token.setToken(hashedToken);
        token.setRawToken(rawToken);

        return refreshTokenRepository.save(token);
    }

    private String hash(String input) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            return Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 algorithm not available", e);
        }
    }
}