Token.java
package com.albertsilva.dev.asjcatalog.domain.recovery;
import java.io.Serializable;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import com.albertsilva.dev.asjcatalog.domain.recovery.enums.TokenType;
import com.albertsilva.dev.asjcatalog.domain.user.User;
import com.albertsilva.dev.asjcatalog.service.exception.InvalidTokenException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
/**
* Token de uso pontual associado a um {@link User}, utilizado nos fluxos de
* ativação de conta e de recuperação de senha.
*
* <p>
* O valor do token ({@code token}) é um UUID aleatório que o sistema entrega ao
* usuário e que depois é apresentado de volta para comprovar a posse do
* e-mail. Cada token tem uma finalidade ({@link TokenType}), uma data de
* expiração absoluta e um indicador de desabilitação.
* </p>
*
* <p>
* <b>Regras e invariantes:</b>
* </p>
* <ul>
* <li>Um token é utilizável enquanto não estiver desabilitado e não tiver
* expirado (ver {@link #isValid()} e {@link #validate(TokenType)})</li>
* <li>A entidade não desabilita o token sozinha após o uso: quem o consome
* deve chamar {@link #disable()}. Além desse método, os setters públicos
* {@code setDisabled} e {@code setExpireDate} permitem alterar o estado
* diretamente</li>
* <li>As fábricas {@link #activationToken(User, long)} e
* {@link #passwordRecoveryToken(User, long)} geram o UUID e calculam a
* expiração; o construtor público aceita esses valores prontos</li>
* <li>{@code createdAt} é definido no construtor e a coluna não é atualizável
* ({@code updatable = false})</li>
* <li>A identidade é definida somente pelo {@code id} (ver
* {@link #equals(Object)})</li>
* </ul>
*
* <p>
* <b>Mapeamento:</b>
* </p>
* <ul>
* <li>Tabela: {@code tb_token}; o valor do token é único na tabela</li>
* <li>Muitos-para-um com {@link User}, carregamento tardio
* ({@code FetchType.LAZY}); {@code Token} é o lado dono, e a coluna
* {@code user_id} é obrigatória. O lado inverso é {@code User.tokens}</li>
* <li>O tipo é persistido pelo nome da constante de {@link TokenType}</li>
* </ul>
*/
@Entity
@Table(name = "tb_token")
public class Token implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* Valor do token: texto opaco (UUID quando criado pelas fábricas), único na
* tabela. É este valor, e não o {@code id}, que circula fora do sistema.
*/
@Column(nullable = false, unique = true)
private String token;
/**
* Usuário ao qual o token pertence (obrigatório). O carregamento é tardio: a
* instância só é lida do banco no primeiro acesso, e isso exige uma sessão
* JPA ainda aberta.
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
/**
* Instante de criação, definido pelo construtor. A coluna não é atualizável.
*/
@Column(nullable = false, updatable = false, columnDefinition = "TIMESTAMP WITHOUT TIME ZONE")
private Instant createdAt;
/**
* Instante absoluto de expiração (obrigatório). Ver {@link #isExpired()}.
*/
@Column(nullable = false, columnDefinition = "TIMESTAMP WITHOUT TIME ZONE")
private Instant expireDate;
/**
* Indica que o token foi invalidado explicitamente (por exemplo, depois de ser
* consumido). Um token desabilitado nunca é considerado válido. Alterado por
* {@link #disable()}.
*/
@Column(nullable = false)
private boolean disabled;
/**
* Finalidade do token, conferida em {@link #validate(TokenType)}.
*/
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TokenType type;
/**
* Construtor sem argumentos reservado à JPA ({@code protected}, não deve ser
* usado diretamente pelo código da aplicação). Deixa todos os campos com o
* valor padrão.
*/
protected Token() {
}
/**
* Cria um token com os valores informados.
*
* <p>
* Define {@code createdAt} como o instante atual e {@code disabled} como
* {@code false}. Os argumentos não são validados; um {@code expireDate}
* nulo, por exemplo, só se manifestaria depois, em {@link #isExpired()}. Para
* criar tokens completos (com UUID e expiração calculada), preferir as
* fábricas estáticas.
* </p>
*
* @param token valor do token (normalmente um UUID)
* @param user usuário dono do token
* @param expireDate instante de expiração
* @param type finalidade do token
*/
public Token(String token, User user, Instant expireDate, TokenType type) {
this.token = token;
this.user = user;
this.createdAt = Instant.now();
this.expireDate = expireDate;
this.type = type;
this.disabled = false;
}
/**
* Indica se o token expirou: é {@code true} quando {@code expireDate} é
* estritamente anterior ao instante atual. No exato instante da expiração o
* token ainda não é considerado expirado.
*
* <p>
* Considera apenas a data; não leva em conta se o token foi desabilitado.
* Pressupõe {@code expireDate} não nulo (caso contrário, lança
* {@link NullPointerException}).
* </p>
*
* @return {@code true} se o token já expirou
*/
public boolean isExpired() {
return expireDate.isBefore(Instant.now());
}
/**
* Indica se o token está utilizável: não desabilitado e não expirado.
*
* <p>
* Não verifica a finalidade ({@link TokenType}) e não lança exceção; para
* validar um token para uma finalidade específica, com motivo da recusa, ver
* {@link #validate(TokenType)}.
* </p>
*
* @return {@code true} se o token não está desabilitado e não expirou
*/
public boolean isValid() {
return !disabled && !isExpired();
}
/**
* Marca o token como desabilitado ({@code disabled = true}). Operação
* idempotente; o domínio não oferece método para reabilitá-lo.
*
* <p>
* O método apenas altera o objeto em memória, sem acessar o banco: a mudança é
* gravada quando a entidade gerenciada é sincronizada pela JPA.
* </p>
*/
public void disable() {
this.disabled = true;
}
/**
* Cria um token de ativação de conta.
*
* <p>
* O valor é um UUID aleatório e a expiração é o instante atual acrescido de
* {@code expirationHours} horas. O token é apenas construído, não persistido, e
* os argumentos não são validados: um número de horas zero ou negativo produz
* um token que já nasce (ou quase) expirado.
* </p>
*
* @param user usuário dono do token
* @param expirationHours validade do token, em horas, a partir de agora
* @return novo token do tipo {@link TokenType#ACTIVATION}
*/
public static Token activationToken(User user, long expirationHours) {
return new Token(UUID.randomUUID().toString(), user,
Instant.now().plus(expirationHours, ChronoUnit.HOURS), TokenType.ACTIVATION);
}
/**
* Cria um token de recuperação de senha.
*
* <p>
* O valor é um UUID aleatório e a expiração é o instante atual acrescido de
* {@code expirationMinutes} minutos. O token é apenas construído, não
* persistido, e os argumentos não são validados: um número de minutos zero ou
* negativo produz um token que já nasce (ou quase) expirado.
* </p>
*
* @param user usuário dono do token
* @param expirationMinutes validade do token, em minutos, a partir de agora
* @return novo token do tipo {@link TokenType#PASSWORD_RECOVERY}
*/
public static Token passwordRecoveryToken(User user, long expirationMinutes) {
return new Token(UUID.randomUUID().toString(), user,
Instant.now().plus(expirationMinutes, ChronoUnit.MINUTES), TokenType.PASSWORD_RECOVERY);
}
/**
* Valida o token para a finalidade esperada, lançando exceção com o motivo da
* recusa.
*
* <p>
* As verificações são feitas nesta ordem, e a primeira que falhar interrompe a
* validação:
* </p>
* <ol>
* <li>tipo diferente do esperado: mensagem {@code error.token.type.invalid}</li>
* <li>token desabilitado: mensagem {@code error.token.disabled}</li>
* <li>token expirado: mensagem {@code error.token.expired}</li>
* </ol>
*
* <p>
* As mensagens são chaves de internacionalização, e não textos finais. O
* método não altera o token e não examina o estado do usuário dono.
* </p>
*
* @param expectedType finalidade para a qual o token está sendo apresentado
* @throws InvalidTokenException se o token não for válido para
* {@code expectedType}
*/
public void validate(TokenType expectedType) {
if (type != expectedType) {
throw new InvalidTokenException("error.token.type.invalid");
}
if (disabled) {
throw new InvalidTokenException("error.token.disabled");
}
if (isExpired()) {
throw new InvalidTokenException("error.token.expired");
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getToken() {
return token;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getExpireDate() {
return expireDate;
}
public void setExpireDate(Instant expireDate) {
this.expireDate = expireDate;
}
/**
* Acessor do indicador de desabilitação. Repare que o nome é
* {@code getDisabled} (e não {@code isDisabled}). Não equivale a
* {@link #isValid()}, que também considera a expiração.
*
* @return {@code true} se o token foi desabilitado
*/
public boolean getDisabled() {
return disabled;
}
public void setDisabled(boolean disabled) {
this.disabled = disabled;
}
public TokenType getType() {
return type;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((id == null) ? 0 : id.hashCode());
return result;
}
/**
* Compara dois tokens pelo identificador ({@code id}), e não pelo valor do
* campo {@code token}.
*
* <p>
* Duas instâncias sem {@code id} (ainda não persistidas) são consideradas
* iguais entre si, e {@link #hashCode()} depende apenas do {@code id}. A
* comparação exige a mesma classe exata ({@code getClass()}), e não apenas
* compatibilidade de tipo.
* </p>
*/
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Token other = (Token) obj;
if (id == null) {
if (other.id != null)
return false;
} else if (!id.equals(other.id))
return false;
return true;
}
}