Integração Shiro e JWT: Autenticação e Autorização em Aplicações Web

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 OAuth2Filter intercepta requisições, extrai o token, valida-o e, se necessário, renova-o. O novo token é armazenado em um ThreadLocal para 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:

  1. Requisição Chega: Uma requisição HTTP é feita para um endpoint protegido.
  2. JwtAuthFilter Atua: O filtro intercepta a requisição.
  3. 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.
  4. Criação do AuthenticationToken: Se o token for válido ou renovado, um JwtAuthenticationToken é criado.
  5. executeLogin(): O filtro chama subject.login(token).
  6. JwtRealm.doGetAuthenticationInfo(): O Shiro delega a autenticação ao JwtRealm. O Realm valida o token, busca o usuário no banco de dados e retorna um AuthenticationInfo com os detalhes do usuário.
  7. Autenticação Bem-Sucedida: Se a autenticação for bem-sucedida, o Subject é autenticado. O objeto do usuário é associado ao Subject.
  8. Requisição para Controller: A requisição prossegue para o JwtAuthFilter e, se o endpoint não for público, continua para o método do controller.
  9. JwtRealm.doGetAuthorizationInfo(): Antes de executar o método do controller anotado com permissões (ex: @RequiresPermissions), o Shiro chama doGetAuthorizationInfo() no Realm para obter as permissões do usuário autanticado.
  10. 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ção UnauthorizedException é lançada.
  11. Retorno da Resposta: O método do controller executa e retorna um resultado.
  12. TokenResponseAspect Atua: O aspecto AOP intercepta o resultado. Se um token renovado estiver disponível no ThreadLocalTokenHolder, ele é adicionado à resposta.
  13. 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 ...
}
   

Tags: java Spring Boot Shiro jwt autenticacao

Publicado em 7-9 05:30