VaultService.java

package com.saltynote.service.service;

import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.saltynote.service.domain.IdentifiableUser;
import com.saltynote.service.domain.VaultEntity;
import com.saltynote.service.domain.VaultType;
import com.saltynote.service.entity.Vault;
import com.saltynote.service.repository.VaultRepository;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Optional;

@Service
@Slf4j
@RequiredArgsConstructor
public class VaultService implements RepositoryService<String, Vault> {

    private final VaultRepository repository;

    private final ObjectMapper objectMapper;

    private final JwtService jwtService;

    // TTL in milliseconds
    @Value("${jwt.refresh_token.ttl}")
    private long refreshTokenTTL;

    public Vault create(@NotNull String userId, VaultType type) {
        return create(userId, type, RandomStringUtils.randomAlphanumeric(8));
    }

    public Vault createVerificationCode(@NotNull String email) {
        return repository.save(new Vault().setEmail(email)
            .setType(VaultType.NEW_ACCOUNT.getValue())
            .setSecret(RandomStringUtils.randomNumeric(6)));
    }

    private Vault create(@NotNull String userId, VaultType type, String secret) {
        return repository.save(new Vault().setUserId(userId).setType(type.getValue()).setSecret(secret));
    }

    public String encode(@NotNull VaultEntity entity) throws JsonProcessingException {
        return Base64.getEncoder().encodeToString(objectMapper.writeValueAsBytes(entity));
    }

    public String encode(@NotNull Vault vault) throws JsonProcessingException {
        return encode(VaultEntity.from(vault));
    }

    public Optional<VaultEntity> decode(@NotNull String encodedValue) {
        try {
            return Optional.of(objectMapper.readValue(Base64.getDecoder().decode(encodedValue), VaultEntity.class));
        }
        catch (IOException e) {
            log.error(e.getMessage(), e);
            return Optional.empty();
        }
    }

    public String createRefreshToken(IdentifiableUser user) {
        String refreshToken = jwtService.createRefreshToken(user);
        Vault v = create(user.getId(), VaultType.REFRESH_TOKEN, refreshToken);
        return v.getSecret();
    }

    /**
     * This is try to find the latest refresh token for given user id. If the refresh
     * token ages below 20%, we will return this refresh token. Otherwise, a new refresh
     * token will be generated and returned.
     * @param user the target user
     * @return the refresh token value
     */
    public String fetchOrCreateRefreshToken(IdentifiableUser user) {
        Optional<Vault> vaultOp = repository.findFirstByUserIdAndTypeOrderByCreatedTimeDesc(user.getId(),
                VaultType.REFRESH_TOKEN.getValue());
        // If refresh token is young enough, then just return it.
        if (vaultOp.isPresent() && isRefreshTokenReusable(vaultOp.get().getSecret())) {
            return vaultOp.get().getSecret();
        }
        // Refresh token is not a kid anymore or no existing refresh token found, a new
        // one should be
        // created.
        return createRefreshToken(user);
    }

    /**
     * Validate given token and return the vault.
     * @param token token
     * @return vault for the token
     */
    public Optional<Vault> findByToken(String token) {
        Optional<VaultEntity> veo = decode(token);
        if (veo.isEmpty()) {
            return Optional.empty();
        }
        VaultEntity ve = veo.get();
        Optional<Vault> vault = repository.findBySecret(ve.getSecret());
        if (vault.isPresent() && !vault.get().getUserId().equals(ve.getUserId())) {
            log.error("User id are not match from decoded token {} and database {}", ve.getUserId(),
                    vault.get().getUserId());
            return Optional.empty();
        }

        return vault;
    }

    @Override
    public Vault create(Vault entity) {
        if (hasValidId(entity)) {
            log.warn("Note id must be empty: {}", entity);
        }
        return repository.save(entity);
    }

    @Override
    public Vault update(Vault entity) {
        checkIdExists(entity);
        return repository.save(entity);
    }

    @Override
    public Optional<Vault> getById(String id) {
        return repository.findById(id);
    }

    @Override
    public void delete(Vault entity) {
        repository.deleteById(entity.getId());
    }

    public void deleteById(String id) {
        repository.deleteById(id);
    }

    public Optional<Vault> findByUserIdAndTypeAndValue(String userId, VaultType type, String secret) {
        return repository.findByUserIdAndTypeAndSecret(userId, type.getValue(), secret);
    }

    public void cleanRefreshTokenByUserId(String userId) {
        repository.deleteByUserIdAndType(userId, VaultType.REFRESH_TOKEN.getValue());
    }

    private boolean isRefreshTokenReusable(String refreshToken) {
        try {
            DecodedJWT decodedJWT = jwtService.verifyRefreshToken(refreshToken);
            return decodedJWT.getExpiresAt().after(new Date(System.currentTimeMillis() + refreshTokenTTL * 8 / 10));
        }
        catch (JWTVerificationException e) {
            return false;
        }
    }

    public Optional<Vault> getByEmailAndSecretAndType(String email, String token, VaultType type) {
        return repository.findByEmailAndSecretAndType(email, token, type.getValue());
    }

    public List<Vault> getByEmail(String email) {
        return repository.findByEmail(email);
    }

    public List<Vault> getByUserIdAndType(String userId, VaultType vaultType) {
        return repository.findByUserIdAndType(userId, vaultType.getValue());
    }

    public List<Vault> getByUserId(String userId) {
        return repository.findByUserId(userId);
    }

}