O Apache Shiro é um framework de segurança poderoso que simplifica a autenticação e autorização em aplicações Java. Quando combinado com JSON Web Tokens (JWT), ele oferece uma solução robusta e escalável, espeicalmente para sistemas distribuídos e single sign-on (SSO).
Como o Shiro Gerencia Autenticação e Autorização?
O Shiro pode persistir credenciais de login, papéis e permissões de usuários utilizando a HttpSession ou um armazenamento externo como o Redis. Através de um sistema de filtros (Filters), o Shiro inspeciona cada requisição HTTP, verificando as informações de autenticação e autorização armazenadas. Se um usuário não estiver autenticado ou não possuir as permissões necessárias, o Shiro retorna uma resposta de erro apropriada.
Ao implementar o módulo de login, após a autenticação bem-sucedida do usuário, é necessário invocar os métodos do Shiro para registrar suas credenciais e informações de papéis/permissões. Para proteger métodos específicos que exigem login ou permissões particulares, basta adicionar anotações Shiro.
JWT em Sistemas de Single Sign-On (SSO)
Em aplicações Java Web tradicionais, a HttpSession é comumente usada para armazenar creednciais de login. Em cenários com balanceamento de carga, se um usuário se autentica em um nó do servidor (Nó A), suas credenciais ficam na HttpSession desse nó. Se uma requisição subsequente for redirecionada para outro nó (Nó B) devido ao balanceamento de carga, o usuário será forçado a se autenticar novamente, o que resulta em uma experiência de usuário ruim.
Ao utilizar JWT, as credenciais do usuário são criptografadas e armazenadas no cliente como um Token. Cada requisição enviada pelo cliente inclui esse Token. Ao receber a requisição, cada nó do servidor pode validar o Token recebido. Se o Token for válido, o servidor assume que o usuário está autenticado e prossegue com o processamento da requisição, independentemente do nó onde o usuário se autenticou originalmente.
JWT para Compatibilidade com Diversos Clientes
A HttpSession tradicional depende de SessionIds armazenados em Cookies do navegador, o que limita a compatibilidade a clientes baseados em navegador. Atualmente, as aplicações web podem ter clientes diversos, como aplicativos móveis (Apps), mini-programas e dispositivos de IoT. O JWT, sendo uma string pura, elimina essa restrição. O cliente pode armazenar o Token de qualquer maneira que preferir, desde que o inclua em requisições subsequentes. Por exemplo, dispositivos de IoT podem armazenar Tokens em bancos de dados SQLite.
Dependências Maven
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.10.0</version> <!-- Versão atualizada -->
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.10.0</version> <!-- Versão atualizada -->
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.3.0</version> <!-- Versão atualizada -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version> <!-- Versão atualizada -->
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.16</version> <!-- Versão atualizada -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Utilitário JWT Personalizado
Este utilitário gerencia a criação e validação de tokens JWT.
package com.example.security.util;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUtil;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.security.exception.SecurityException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
@Slf4j
public class JwtTokenUtil {
@Value("${security.jwt.secret}")
private String jwtSecret;
@Value("${security.jwt.expire-days}")
private int tokenExpireDays;
/**
* Cria um token JWT assinado.
* @param userId O ID do usuário a ser incluído no token.
* @return O token JWT gerado.
*/
public String generateToken(long userId) {
Date expireDate = DateUtil.offset(new Date(), DateField.DAY_OF_YEAR, tokenExpireDays).toJdkDate();
Algorithm algorithm = Algorithm.HMAC256(jwtSecret);
JWTCreator.Builder builder = JWT.create();
// Adiciona o ID do usuário como claim e a data de expiração
String token = builder.withClaim("userId", userId)
.withExpiresAt(expireDate)
.sign(algorithm);
log.debug("Token JWT gerado para userId {}: {}", userId, token);
return token;
}
/**
* Extrai o ID do usuário de um token JWT.
* @param token O token JWT a ser decodificado.
* @return O ID do usuário.
* @throws SecurityException Se o token for inválido ou não puder ser decodificado.
*/
public long getUserIdFromToken(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
long userId = jwt.getClaim("userId").asLong();
log.debug("Extraído userId {} do token.", userId);
return userId;
} catch (JWTDecodeException e) {
log.error("Falha ao decodificar o token JWT: {}", token, e);
throw new SecurityException("Token inválido.");
}
}
/**
* Verifica a validade de um token JWT. Lança exceção se o token estiver expirado ou inválido.
* @param token O token JWT a ser verificado.
* @throws TokenExpiredException Se o token estiver expirado.
* @throws JWTDecodeException Se o token for mal formatado.
*/
public void validateToken(String token) {
Algorithm algorithm = Algorithm.HMAC256(jwtSecret);
JWTVerifier verifier = JWT.require(algorithm).build();
try {
verifier.verify(token);
log.debug("Token JWT validado com sucesso.");
} catch (TokenExpiredException e) {
log.warn("Token JWT expirado: {}", token, e);
throw e; // Relança para ser tratada pelo filtro
} catch (JWTDecodeException e) {
log.error("Token JWT mal formatado ou inválido: {}", token, e);
throw new SecurityException("Token inválido.");
}
}
}
Token de Autenticação Personalizado
Um token de autenticação personalizado para encapsular o JWT.
package com.example.security.token;
import org.apache.shiro.authc.AuthenticationToken;
/**
* Token de autenticação personalizado que encapsula um token JWT.
*/
public class JwtAuthenticationToken implements AuthenticationToken {
private final String token;
public JwtAuthenticationToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token; // O principal é o próprio token JWT
}
@Override
public Object getCredentials() {
return token; // As credenciais também são o token JWT
}
public String getTokenString() {
return token;
}
}
Realm Shiro para JWT
Este Realm lida com a autenticação e autorização usando JWT.
package com.example.security.realm;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.example.security.exception.SecurityException;
import com.example.security.token.JwtAuthenticationToken;
import com.example.security.util.JwtTokenUtil;
import com.example.user.domain.User; // Assumindo que User é a entidade do usuário
import com.example.user.service.UserService; // Assumindo que UserService lida com dados do usuário
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.util.Set;
@Component
@Slf4j
public class JwtRealm extends AuthorizingRealm {
@Autowired
@Lazy // Lazy para evitar dependência circular com ShiroFilter
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserService userService;
@Override
public boolean supports(AuthenticationToken token) {
// Este Realm só suporta tokens do tipo JwtAuthenticationToken
return token instanceof JwtAuthenticationToken;
}
/**
* Método de autorização: obtém as permissões do usuário.
* Chamado durante a verificação de permissões (ex: @RequiresPermissions).
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// O principal geralmente é o objeto User ou seu ID
User user = (User) principals.getPrimaryPrincipal();
if (user != null) {
// TODO: Buscar permissões do usuário no serviço
Set<string> userPermissions = userService.getUserPermissions(user.getUserId());
authorizationInfo.setStringPermissions(userPermissions);
log.debug("Permissões obtidas para o usuário {}: {}", user.getUserId(), userPermissions);
} else {
log.warn("Principal não encontrado durante a autorização.");
}
return authorizationInfo;
}
/**
* Método de autenticação: verifica as credenciais do usuário.
* Chamado durante o login (ex: subject.login(token)).
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) token;
String tokenString = jwtToken.getTokenString();
try {
// 1. Valida o token JWT (expiração, assinatura)
jwtTokenUtil.validateToken(tokenString);
// 2. Extrai o ID do usuário do token
long userId = jwtTokenUtil.getUserIdFromToken(tokenString);
// 3. Busca informações do usuário no serviço
User user = userService.findUserById(userId);
if (user == null) {
log.error("Usuário não encontrado para o ID do token: {}", userId);
throw new UnknownAccountException("Conta de usuário não encontrada.");
}
// TODO: Implementar lógica para verificar se a conta está bloqueada ou inativa
if (!user.isActive()) { // Assumindo que User tem um método isActive()
log.warn("Conta de usuário {} está inativa ou bloqueada.", userId);
throw new LockedAccountException("Conta de usuário inativa ou bloqueada.");
}
// 4. Cria e retorna o objeto AuthenticationInfo
// O principal pode ser o objeto User completo ou apenas o ID, dependendo da necessidade
// As credenciais são o token JWT para verificação posterior se necessário
return new SimpleAuthenticationInfo(user, tokenString, getName());
} catch (TokenExpiredException e) {
log.warn("Token JWT expirou: {}", tokenString);
throw new AuthenticationException("Token expirado. Por favor, faça login novamente.");
} catch (SecurityException e) {
log.error("Erro de segurança ao autenticar token: {}", tokenString, e);
throw new AuthenticationException("Falha na autenticação devido a token inválido.");
} catch (Exception e) {
log.error("Erro inesperado durante a autenticação JWT para token: {}", tokenString, e);
throw new AuthenticationException("Erro interno do servidor durante a autenticação.");
}
}
}
</string>
Renovação de Token e Cache
Por que Renovar Tokens JWT?
Tokens JWT possuem um tempo de expiração definido. Uma vez expirados, o usuário precisa se autenticar novamente. Em sistemas que exigem alta disponibilidade e boa experiência do usuário, como em aplicações com sessões HttpSession (que são estendidas automaticamente se houver atividade), a expiração rígida do JWT pode ser inconveniente. Para mitigar isso, podemos implementar uma estratégia de renovação de token.
Existem duas abordagens comuns para gerenciar a expiração de tokens JWT:
- Duplo Token: Utilizar um token de acesso (curta duração) e um token de atualização (longa duração).
- Cache de Token: Armazenar tokens no Redis (ou outro cache) com um tempo de vida maior que o token original. A expiração do token no cache é gerenciada dinamicamente.
Focaremos na abordagem de cache de token.
Estratégia de Cache de Token com Redis
Nesta estratégia, o token JWT original é armazenado no Redis com um tempo de expiração estendido (por exemplo, o dobro do tempo de vida do token original). Quando um token expira, o sistema verifica no Redis se existe um cache para esse token. O fluxo é o seguinte:
- Token Expirado e Cache Não Existe: Se o token JWT expirou e não há um registro correspondente no Redis, significa que o período de atividade do usuário excedeu o limite aceitável (por exemplo, mais de 15 dias de inatividade). O usuário deve ser forçado a fazer login novamente.
- Token Expirado, mas Cache Existe: Se o token JWT expirou, mas um registro do token ainda existe no Redis, isso indica que o usuário esteve ativo dentro do período de tolerância. Neste caso, um novo token JWT é gerado e retornado ao cliente. Esse novo token também é armazenado no Redis, atualizando sua entrada. Essa operação é conhecida como "renovação de token".
Atualização do Token pelo Cliente
Quando o servidor renova um token, ele gera um novo token e o inclui na resposta. O cliente precisa ser capaz de identificar essa resposta e atualizar seu token local. Uma maneira simples de fazer isso é verificar se a resposta de uma requisição AJAX contém um novo token. Se contiver, o cliente deve armazenar este novo token para uso em requisições futuras.
Injeção do Novo Token na Resposta
Para injetar o novo token na resposta HTTP de forma elegante, podemos usar um filtro Shiro (OAuth2Filter) e AOP (Aspect-Oriented Programming).
- O
OAuth2Filterintercepta requisições, extrai o token, valida-o e, se necessário, renova-o. O novo token é armazenado em umThreadLocalpara ser acessível em outras partes da requisição. - Um aspecto AOP pode interceptar o retorno de métodos de controller. Se um novo token estiver presente no
ThreadLocal, ele é adicionado à resposta (geralmente um objeto R ou ResponseEntity).
Utilizar ThreadLocal é crucial aqui, pois garante que o token renovado seja passado de forma segura e thread-safe do filtro para o aspecto AOP, já que ambos operam dentro do mesmo ciclo de requisição (e, portanto, no mesmo thread).
Classe Auxiliar ThreadLocalToken
Esta classe utiliza ThreadLocal para armazenar o token atual de forma segura entre os componentes da requisição.
package com.example.security.token;
import org.springframework.stereotype.Component;
/**
* Armazena o token JWT atual em um ThreadLocal para acesso seguro entre componentes.
*/
@Component
public class ThreadLocalTokenHolder {
private final ThreadLocal<string> tokenStorage = new ThreadLocal<>();
/**
* Define o token JWT para o thread atual.
* @param token O token JWT a ser armazenado.
*/
public void setToken(String token) {
tokenStorage.set(token);
log.trace("Token JWT definido para o thread atual: {}", token);
}
/**
* Obtém o token JWT do thread atual.
* @return O token JWT armazenado, ou null se não houver nenhum.
*/
public String getToken() {
String token = tokenStorage.get();
log.trace("Obtendo token JWT do thread atual: {}", token);
return token;
}
/**
* Remove o token JWT do thread atual. Deve ser chamado após o uso.
*/
public void clear() {
tokenStorage.remove();
log.trace("Token JWT removido do thread atual.");
}
}
</string>
OAuth2Filter Personalizado
Este filtro intercepta requisições, valida o token JWT, renova-o se necessário e o disponibiliza para o Shiro e para o AOP.
package com.example.security.filter;
import cn.hutool.core.util.StrUtil;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.example.security.token.JwtAuthenticationToken;
import com.example.security.token.ThreadLocalTokenHolder;
import com.example.security.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Scope;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Component
@Scope("prototype") // Necessário para que cada requisição tenha sua própria instância
@Slf4j
public class JwtAuthFilter extends AuthenticatingFilter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private ThreadLocalTokenHolder tokenHolder;
@Autowired
private RedisTemplate<string object=""> redisTemplate; // Usando Redis para cache
@Value("${security.jwt.cache-expire-hours}")
private int tokenCacheExpireHours; // Tempo de expiração do cache em horas
/**
* Extrai o token JWT do cabeçalho de autorização da requisição.
* @param request A requisição HTTP.
* @return O token JWT, ou null se não encontrado.
*/
private String getJwtToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (StrUtil.isNotBlank(authHeader) && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7); // Remove "Bearer "
}
// Tenta obter do parâmetro da URL se não estiver no header
String tokenParam = request.getParameter("token");
if (StrUtil.isNotBlank(tokenParam)) {
return tokenParam;
}
return null;
}
/**
* Cria o objeto de token de autenticação Shiro.
*/
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
String jwtTokenString = getJwtToken((HttpServletRequest) request);
if (jwtTokenString == null) {
return null; // Não há token para autenticar
}
return new JwtAuthenticationToken(jwtTokenString);
}
/**
* Determina se o acesso à requisição é permitido sem passar pelo Shiro.
* Permite requisições OPTIONS (CORS preflight) e libera caminhos específicos se configurado.
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// Permite requisições OPTIONS (preflight do CORS)
if (httpRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
return true;
}
// TODO: Adicionar lógica para liberar caminhos estáticos ou públicos, se necessário
// Ex: if (isPublicPath(httpRequest.getRequestURI())) return true;
return super.isAccessAllowed(request, response, mappedValue);
}
/**
* Lida com o acesso negado: tenta autenticar o usuário.
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// Configura cabeçalhos CORS para permitir requisições de origens diferentes
httpResponse.setHeader("Access-Control-Allow-Origin", httpRequest.getHeader("Origin"));
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpResponse.setHeader("Access-Control-Allow-Headers", httpRequest.getHeader("Access-Control-Request-Headers"));
httpResponse.setHeader("Content-Type", "application/json;charset=UTF-8");
String token = getJwtToken(httpRequest);
if (token == null) {
log.debug("Nenhum token JWT encontrado na requisição.");
// Se não houver token, pode ser uma requisição pública ou falha de autenticação inicial
if (isLoginRequest(request, response)) { // Verifica se é uma rota de login
return executeLogin(request, response); // Tenta executar o login (para rotas públicas)
} else {
return handleLoginFailure(request, response, new AuthenticationException("Token não fornecido."));
}
}
try {
// Tenta validar o token original
jwtTokenUtil.validateToken(token);
log.debug("Token JWT original válido.");
// Armazena o token original no ThreadLocal para o AOP
tokenHolder.setToken(token);
// Continua a cadeia de filtros normalmente
return super.onAccessDenied(request, response);
} catch (TokenExpiredException e) {
log.warn("Token JWT expirado: {}", token);
// Tenta renovar o token usando o cache Redis
if (tryRefreshToken(token, httpResponse)) {
// Se o token foi renovado com sucesso, o novo token está no ThreadLocal
// Chama executeLogin para que o Shiro processe o novo token
return executeLogin(request, response);
} else {
// Se não pôde renovar, força o login novamente
return handleLoginFailure(request, response, new AuthenticationException("Token expirado. Faça login novamente."));
}
} catch (JWTDecodeException | SecurityException e) {
log.error("Token JWT inválido ou mal formatado: {}", token, e);
return handleLoginFailure(request, response, new AuthenticationException("Token inválido."));
} catch (Exception e) {
log.error("Erro inesperado durante o processamento do token: {}", token, e);
return handleLoginFailure(request, response, new AuthenticationException("Erro interno do servidor."));
}
}
/**
* Tenta renovar o token JWT usando o cache Redis.
* @param expiredToken O token JWT expirado.
* @param response A resposta HTTP para enviar o novo token.
* @return true se o token foi renovado com sucesso, false caso contrário.
*/
private boolean tryRefreshToken(String expiredToken, HttpServletResponse response) {
String cacheKey = "jwt_refresh_cache:" + expiredToken; // Chave única para o cache do token expirado
// Verifica se existe um cache para este token expirado
if (Boolean.TRUE.equals(redisTemplate.hasKey(cacheKey))) {
try {
long userId = jwtTokenUtil.getUserIdFromToken(expiredToken); // Extrai userId do token expirado
String newToken = jwtTokenUtil.generateToken(userId); // Gera um novo token
// Atualiza o cache no Redis com o novo token e tempo de expiração estendido
redisTemplate.opsForValue().set(cacheKey, newToken, tokenCacheExpireHours, TimeUnit.HOURS);
log.info("Token JWT renovado com sucesso para userId {}. Novo token enviado na resposta.", userId);
// Armazena o novo token no ThreadLocal para ser pego pelo AOP
tokenHolder.setToken(newToken);
// Adiciona o novo token ao cabeçalho de resposta
response.setHeader("Authorization", "Bearer " + newToken);
response.setHeader("X-New-Token", newToken); // Um cabeçalho adicional para clareza
return true;
} catch (Exception e) {
log.error("Falha ao renovar token JWT e atualizar cache Redis.", e);
// Se houver erro na renovação, não podemos prosseguir
return false;
}
} else {
log.warn("Token expirado e sem cache de renovação encontrado no Redis: {}", expiredToken);
return false; // Não há cache para renovar
}
}
/**
* Lida com falhas de login, enviando a resposta de erro apropriada.
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException ae, ServletRequest request, ServletResponse response) throws IOException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(HttpStatus.SC_UNAUTHORIZED); // 401 Unauthorized
httpResponse.setContentType("application/json;charset=UTF-8");
// Certifique-se de que os cabeçalhos CORS estão configurados aqui também, se aplicável
httpResponse.setHeader("Access-Control-Allow-Origin", ((HttpServletRequest) request).getHeader("Origin"));
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
String errorMessage = ae.getMessage() != null ? ae.getMessage() : "Falha na autenticação.";
log.debug("Falha na autenticação: {}", errorMessage);
// Escreve a mensagem de erro no corpo da resposta
String jsonError = String.format("{\"status\": %d, \"message\": \"%s\"}", HttpStatus.SC_UNAUTHORIZED, errorMessage);
httpResponse.getWriter().print(jsonError);
return false; // Indica que o processamento parou aqui
}
/**
* Método auxiliar para centralizar o tratamento de falhas de login.
*/
private boolean handleLoginFailure(ServletRequest request, ServletResponse response, AuthenticationException ae) throws IOException {
// Limpa o token do ThreadLocal em caso de falha
tokenHolder.clear();
return onLoginFailure(null, ae, request, response);
}
/**
* Executa o login através do Shiro, após a criação do token.
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
AuthenticationToken token = createToken(request, response);
if (token == null) {
// Se não houver token (por exemplo, em requisições OPTIONS permitidas), não faz nada
return true;
}
try {
getSubject(request, response).login(token);
// Se o login for bem-sucedido, o token está vinculado ao Subject
return true;
} catch (AuthenticationException e) {
// Se o login falhar, chama onLoginFailure
return onLoginFailure(token, e, request, response);
}
}
@Override
public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// Configura cabeçalhos CORS para requisições OPTIONS e outras
httpResponse.setHeader("Access-Control-Allow-Origin", httpRequest.getHeader("Origin"));
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpResponse.setHeader("Access-Control-Allow-Headers", httpRequest.getHeader("Access-Control-Request-Headers"));
// Se for uma requisição OPTIONS, apenas retorna sucesso imediato após configurar CORS
if (httpRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpResponse.setStatus(HttpStatus.SC_OK);
return;
}
// Chama o método principal do Shiro para processar a requisição
// O onAccessDenied será chamado se isAccessAllowed retornar false
try {
super.doFilterInternal(request, response, chain);
} finally {
// Garante que o ThreadLocal seja limpo após o processamento da requisição
tokenHolder.clear();
}
}
}
</string>
Configuração do Shiro (ShiroConfig)
Esta classe configura o gerenciador de segurança do Shiro, o filtro JWT e os beans necessários.
package com.example.security.config;
import com.example.security.filter.JwtAuthFilter;
import com.example.security.realm.JwtRealm;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
/**
* Configura o Gerenciador de Segurança do Shiro.
* @param jwtRealm O Realm customizado para JWT.
* @return O SecurityManager configurado.
*/
@Bean("securityManager")
public SecurityManager securityManager(@Autowired JwtRealm jwtRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(jwtRealm); // Define o Realm customizado
// Desabilita o recurso "remember me" pois usamos JWT stateless
securityManager.setRememberMeManager(null);
return securityManager;
}
/**
* Configura o ShiroFilterFactoryBean para definir as regras de filtro das requisições.
* @param securityManager O SecurityManager configurado.
* @param jwtAuthFilter O filtro JWT customizado.
* @return O ShiroFilterFactoryBean configurado.
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(@Autowired SecurityManager securityManager,
@Autowired @Lazy JwtAuthFilter jwtAuthFilter) { // @Lazy para evitar dependência circular
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
// Mapeamento de filtros customizados
Map<string filter=""> filters = new HashMap<>();
filters.put("jwt", jwtAuthFilter); // Mapeia o filtro JWT
shiroFilter.setFilters(filters);
// Mapeamento de regras de URL (FilterChainDefinitionMap)
// A ordem é importante: caminhos mais específicos devem vir primeiro.
Map<string string=""> filterChainDefinitionMap = new LinkedHashMap<>();
// Rotas públicas (anon) - não requerem autenticação
filterChainDefinitionMap.put("/public/**", "anon"); // Exemplo: rotas públicas
filterChainDefinitionMap.put("/auth/login", "anon"); // Rota de login
filterChainDefinitionMap.put("/auth/refresh-token", "anon"); // Rota para renovar token
filterChainDefinitionMap.put("/swagger/**", "anon");
filterChainDefinitionMap.put("/v2/api-docs", "anon");
filterChainDefinitionMap.put("/swagger-ui.html", "anon");
filterChainDefinitionMap.put("/swagger-resources/**", "anon");
filterChainDefinitionMap.put("/webjars/**", "anon");
filterChainDefinitionMap.put("/captcha.jpg", "anon"); // Gerador de captcha
// Rotas que requerem autenticação (jwt)
// Todas as outras requisições devem passar pelo filtro JWT
filterChainDefinitionMap.put("/**", "jwt");
shiroFilter.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilter;
}
/**
* Bean para gerenciar o ciclo de vida de beans Shiro.
*/
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
/**
* Habilita a anotação @RequiresPermissions em controllers.
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Autowired SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
</string></string>
Aspecto AOP para Adicionar Token na Resposta
Este aspecto intercepta o retorno de métodos de controller para adicionar o token renovado na resposta.
package com.example.security.aspect;
import com.example.common.util.R; // Assumindo uma classe R genérica para respostas
import com.example.security.token.ThreadLocalTokenHolder;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Aspecto AOP para interceptar retornos de métodos de controller e adicionar o token JWT renovado.
*/
@Aspect
@Component
@Slf4j
public class TokenResponseAspect {
@Autowired
private ThreadLocalTokenHolder tokenHolder;
/**
* Define o ponto de corte para interceptar métodos em controllers.
* Intercepta métodos públicos em classes no pacote com.example.controller.*
*/
@Pointcut("execution(public * com.example.controller.*.*(..)))")
public void controllerMethods() {}
/**
* Intercepta a execução dos métodos de controller.
* @param joinPoint O ponto de junção da execução.
* @return O resultado da execução do método de controller.
*/
@Around("controllerMethods()")
public Object addTokenToResponse(ProceedingJoinPoint joinPoint) throws Throwable {
// Executa o método do controller
Object result = joinPoint.proceed();
// Verifica se o resultado é um objeto R (ou similar) e se há um token renovado
if (result instanceof R) {
R response = (R) result;
String newToken = tokenHolder.getToken();
if (newToken != null) {
log.debug("Adicionando token JWT renovado à resposta.");
response.put("token", newToken); // Adiciona o token à resposta R
// Limpa o token do ThreadLocal após adicioná-lo à resposta
tokenHolder.clear();
}
}
// Se o resultado não for R, ou se não houver token, retorna o resultado original
return result;
}
}
Manipulação de Exceções
Uma classe centralizada para tratar exceções, incluindo aquelas relacionadas à segurança.
package com.example.security.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Exceção personalizada para erros de segurança.
*/
@Getter
@ResponseStatus(HttpStatus.UNAUTHORIZED) // Define o status HTTP padrão como 401
public class SecurityException extends RuntimeException {
private final String msg;
private final int status;
public SecurityException(String message) {
super(message);
this.msg = message;
this.status = HttpStatus.UNAUTHORIZED.value(); // Valor 401
}
public SecurityException(String message, Throwable cause) {
super(message, cause);
this.msg = message;
this.status = HttpStatus.UNAUTHORIZED.value();
}
public SecurityException(String message, int status) {
super(message);
this.msg = message;
this.status = status;
}
}
package com.example.common.advice;
import com.example.security.exception.SecurityException;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
/**
* Advice global para tratamento de exceções em controllers REST.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* Lida com exceções específicas de segurança (SecurityException).
*/
@ExceptionHandler(SecurityException.class)
public ResponseEntity<object> handleSecurityException(SecurityException ex, WebRequest request) {
log.warn("Exceção de segurança capturada: {}", ex.getMessage());
return new ResponseEntity<>(ex.getMsg(), HttpStatus.valueOf(ex.getStatus()));
}
/**
* Lida com exceções de autenticação Shiro.
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<object> handleAuthenticationException(AuthenticationException ex, WebRequest request) {
log.warn("Exceção de autenticação Shiro capturada: {}", ex.getMessage());
return new ResponseEntity<>(ex.getMessage(), HttpStatus.UNAUTHORIZED); // 401
}
/**
* Lida com exceções de autorização Shiro (UnauthorizedException).
*/
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<object> handleUnauthorizedException(UnauthorizedException ex, WebRequest request) {
log.warn("Exceção de autorização Shiro capturada: {}", ex.getMessage());
return new ResponseEntity<>("Você não tem permissão para acessar este recurso.", HttpStatus.FORBIDDEN); // 403
}
/**
* Lida com todas as outras exceções não tratadas.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<object> handleGeneralException(Exception ex, WebRequest request) {
log.error("Erro inesperado capturado: ", ex);
String errorMessage = "Ocorreu um erro interno no servidor.";
return new ResponseEntity<>(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR); // 500
}
}
</object></object></object></object>
Fluxo de Execução de Autenticação e Autorização
Com a configuração completa, o fluxo é o seguinte:
- Requisição Chega: Uma requisição HTTP é feita para um endpoint protegido.
JwtAuthFilterAtua: O filtro intercepta a requisição.- Extração e Validação do Token: O filtro extrai o token JWT do cabeçalho. Se o token estiver expirado, ele tenta renová-lo usando Redis. Se o token for inválido ou não puder ser renovado, uma resposta 401 é retornada.
- Criação do
AuthenticationToken: Se o token for válido ou renovado, umJwtAuthenticationTokené criado. executeLogin(): O filtro chamasubject.login(token).JwtRealm.doGetAuthenticationInfo(): O Shiro delega a autenticação aoJwtRealm. O Realm valida o token, busca o usuário no banco de dados e retorna umAuthenticationInfocom os detalhes do usuário.- Autenticação Bem-Sucedida: Se a autenticação for bem-sucedida, o
Subjecté autenticado. O objeto do usuário é associado aoSubject. - Requisição para Controller: A requisição prossegue para o
JwtAuthFiltere, se o endpoint não for público, continua para o método do controller. JwtRealm.doGetAuthorizationInfo(): Antes de executar o método do controller anotado com permissões (ex:@RequiresPermissions), o Shiro chamadoGetAuthorizationInfo()no Realm para obter as permissões do usuário autanticado.- Verificação de Permissões: O Shiro compara as permissões obtidas com as exigidas pela anotação
@RequiresPermissions. Se o usuário tiver as permissões necessárias, o método do controller é executado. Caso contrário, uma exceçãoUnauthorizedExceptioné lançada. - Retorno da Resposta: O método do controller executa e retorna um resultado.
TokenResponseAspectAtua: O aspecto AOP intercepta o resultado. Se um token renovado estiver disponível noThreadLocalTokenHolder, ele é adicionado à resposta.- Resposta Enviada: A resposta final, possivelmente com o token renovado, é enviada ao cliente.
Busca de Informações do Usuário
O método doGetAuthenticationInfo no JwtRealm é responsável por buscar os detalhes do usuário com base no ID extraído do token. É aqui que a lógica para verificar o status do usuário (ativo/inativo, bloqueado) deve ser implementada.
// Dentro de JwtRealm.java
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) token;
String tokenString = jwtToken.getTokenString();
try {
jwtTokenUtil.validateToken(tokenString);
long userId = jwtTokenUtil.getUserIdFromToken(tokenString);
// Busca o usuário no serviço
User user = userService.findUserById(userId); // Método assumido no UserService
if (user == null) {
log.error("Usuário com ID {} não encontrado.", userId);
throw new UnknownAccountException("Conta de usuário não encontrada.");
}
// Verifica se a conta está ativa ou bloqueada
if (!user.isActive()) { // Assumindo que a entidade User tem um método isActive()
log.warn("Tentativa de login com conta inativa ou bloqueada para o usuário: {}", userId);
throw new LockedAccountException("Sua conta está inativa ou bloqueada. Contate o administrador.");
}
// Cria e retorna o objeto AuthenticationInfo
// O principal é o objeto User, que contém ID, nome, status, etc.
return new SimpleAuthenticationInfo(user, tokenString, getName());
} catch (TokenExpiredException e) {
log.warn("Token expirado durante a autenticação.");
throw new AuthenticationException("Token expirado. Por favor, faça login novamente.");
} catch (SecurityException e) {
log.error("Erro de segurança ao autenticar: {}", e.getMessage());
throw new AuthenticationException("Falha na autenticação: " + e.getMessage());
} catch (Exception e) {
log.error("Erro inesperado durante a autenticação: ", e);
throw new AuthenticationException("Erro interno do servidor durante a autenticação.");
}
}
Implementação do Método de Autorização
O método doGetAuthorizationInfo é crucial para verificar as permissões do usuário. Ele deve consultar o serviço de usuário para obter as permissões associadas ao usuário autenticado.
// Dentro de JwtRealm.java
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// Obtém o objeto User do principal (definido em doGetAuthenticationInfo)
User user = (User) principals.getPrimaryPrincipal();
if (user != null) {
long userId = user.getUserId(); // Assumindo que User tem getUserId()
// Busca as permissões do usuário através do UserService
Set<string> userPermissions = userService.getUserPermissions(userId); // Método assumido no UserService
if (userPermissions != null && !userPermissions.isEmpty()) {
authorizationInfo.setStringPermissions(userPermissions);
log.debug("Permissões obtidas para o usuário {}: {}", userId, userPermissions);
} else {
log.debug("Nenhuma permissão encontrada para o usuário {}.", userId);
// Opcional: Adicionar permissões padrão ou lançar exceção se necessário
}
} else {
log.error("Principal (usuário) não encontrado na coleção de principais durante a autorização.");
// Pode lançar uma exceção aqui se um principal válido for sempre esperado
}
return authorizationInfo;
}
</string>
Exemplo de uso em um Controller:
// Dentro de um Controller, por exemplo, UserController.java
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/add")
@ApiOperation("Adiciona um novo usuário")
// Exige que o usuário tenha a permissão "ROOT" OU "USER:ADD"
@RequiresPermissions(value = {"ROOT", "USER:ADD"}, logical = Logical.OR)
public R addUser(@RequestBody User newUser) {
// Lógica para adicionar o usuário
userService.save(newUser);
return R.ok("Usuário adicionado com sucesso.");
}
@GetMapping("/profile")
@ApiOperation("Obtém o perfil do usuário autenticado")
// Exige que o usuário tenha a permissão "USER:READ"
@RequiresPermissions("USER:READ")
public R getUserProfile(@ShiroPrincipal User currentUser) { // Obtém o usuário autenticado via Shiro
// Lógica para retornar o perfil do usuário
return R.ok(currentUser);
}
// ... outros métodos ...
}