Segurança REST com JWT usando Java e Spring Security
Publicados: 2022-03-11Segurança
A segurança é inimiga da conveniência e vice-versa. Essa afirmação vale para qualquer sistema, virtual ou real, desde a entrada da casa física até plataformas de web banking. Os engenheiros estão constantemente tentando encontrar o equilíbrio certo para o caso de uso determinado, inclinando-se para um lado ou para o outro. Normalmente, quando uma nova ameaça aparece, nos afastamos da segurança e da conveniência. Então, vemos se podemos recuperar alguma conveniência perdida sem reduzir muito a segurança. Além disso, esse círculo vicioso continua para sempre.
Vamos tentar examinar o estado da segurança REST hoje, usando um tutorial de segurança simples do Spring para demonstrá-lo em ação.
Os serviços REST (representational State Transfer) começaram como uma abordagem extremamente simplificada para Web Services que tinham especificações enormes e formatos complicados, como WSDL para descrever o serviço ou SOAP para especificar o formato da mensagem. Em REST, não temos nada disso. Podemos descrever o serviço REST em um arquivo de texto simples e usar qualquer formato de mensagem que desejarmos, como JSON, XML ou mesmo texto simples novamente. A abordagem simplificada também foi aplicada à segurança dos serviços REST; nenhum padrão definido impõe uma maneira particular de autenticar usuários.
Embora os serviços REST não tenham muito especificado, um importante é a falta de estado. Isso significa que o servidor não mantém nenhum estado do cliente, com as sessões como um bom exemplo. Assim, o servidor responde a cada solicitação como se fosse a primeira que o cliente fez. No entanto, mesmo agora, muitas implementações ainda usam autenticação baseada em cookies, que é herdada do design arquitetônico padrão do site. A abordagem sem estado do REST torna os cookies de sessão inapropriados do ponto de vista da segurança, mas, no entanto, eles ainda são amplamente utilizados. Além de ignorar a apatridia necessária, a abordagem simplificada veio como uma compensação de segurança esperada. Comparado com o padrão WS-Security usado para Web Services, é muito mais fácil criar e consumir serviços REST, portanto, a conveniência aumentou. A desvantagem é uma segurança muito pequena; sequestro de sessão e falsificação de solicitação entre sites (XSRF) são os problemas de segurança mais comuns.
Na tentativa de se livrar das sessões do cliente do servidor, alguns outros métodos foram usados ocasionalmente, como autenticação HTTP básica ou Digest. Ambos usam um cabeçalho Authorization
para transmitir credenciais de usuário, com alguma codificação (HTTP Basic) ou criptografia (HTTP Digest) adicionada. Claro, eles carregavam as mesmas falhas encontradas em sites: HTTP Basic teve que ser usado sobre HTTPS, já que nome de usuário e senha são enviados em codificação base64 facilmente reversível, e HTTP Digest forçou o uso de hash MD5 obsoleto que é comprovadamente inseguro.
Finalmente, algumas implementações usaram tokens arbitrários para autenticar clientes. Esta opção parece ser a melhor que temos, por enquanto. Se implementado corretamente, corrige todos os problemas de segurança de HTTP Basic, HTTP Digest ou cookies de sessão, é simples de usar e segue o padrão stateless.
No entanto, com esses tokens arbitrários, há pouco padrão envolvido. Cada provedor de serviços tinha sua ideia do que colocar no token e como codificá-lo ou criptografá-lo. O consumo de serviços de diferentes provedores exigia tempo de configuração adicional, apenas para se adaptar ao formato de token específico usado. Os outros métodos, por outro lado (cookie de sessão, HTTP Basic e HTTP Digest) são bem conhecidos pelos desenvolvedores, e quase todos os navegadores em todos os dispositivos funcionam com eles imediatamente. Estruturas e linguagens estão prontas para esses métodos, com funções integradas para lidar com cada uma delas sem problemas.
Autenticação JWT
JWT (abreviado de JSON Web Token) é a padronização que faltava para usar tokens para autenticar na Web em geral, não apenas para serviços REST. Atualmente, está em status de rascunho como RFC 7519. É robusto e pode transportar muitas informações, mas ainda é simples de usar, embora seu tamanho seja relativamente pequeno. Como qualquer outro token, o JWT pode ser usado para passar a identidade de usuários autenticados entre um provedor de identidade e um provedor de serviços (que não são necessariamente os mesmos sistemas). Ele também pode carregar todas as reivindicações do usuário, como dados de autorização, para que o provedor de serviços não precise entrar no banco de dados ou em sistemas externos para verificar as funções e permissões do usuário para cada solicitação; esses dados são extraídos do token.
Veja como a segurança JWT foi projetada para funcionar:
- Os clientes efetuam login enviando suas credenciais para o provedor de identidade.
- O provedor de identidade verifica as credenciais; se tudo estiver OK, ele recupera os dados do usuário, gera um JWT contendo detalhes do usuário e permissões que serão usadas para acessar os serviços e também define a expiração no JWT (que pode ser ilimitada).
- O provedor de identidade assina e, se necessário, criptografa o JWT e o envia ao cliente como resposta à solicitação inicial com credenciais.
- O cliente armazena o JWT por um período de tempo limitado ou ilimitado, dependendo da expiração definida pelo provedor de identidade.
- O cliente envia o JWT armazenado em um cabeçalho de autorização para cada solicitação ao provedor de serviços.
- Para cada solicitação, o provedor de serviços pega o JWT do cabeçalho
Authorization
e o descriptografa, se necessário, valida a assinatura e, se estiver tudo certo, extrai os dados e permissões do usuário. Com base apenas nesses dados, e novamente sem procurar mais detalhes no banco de dados ou entrar em contato com o provedor de identidade, ele pode aceitar ou negar a solicitação do cliente. O único requisito é que a identidade e os provedores de serviços tenham um acordo de criptografia para que o serviço possa verificar a assinatura ou mesmo descriptografar qual identidade foi criptografada.
Esse fluxo permite grande flexibilidade, mantendo as coisas seguras e fáceis de desenvolver. Ao usar essa abordagem, é fácil adicionar novos nós de servidor ao cluster do provedor de serviços, inicializando-os com apenas a capacidade de verificar a assinatura e descriptografar os tokens fornecendo a eles uma chave secreta compartilhada. Nenhuma replicação de sessão, sincronização de banco de dados ou comunicação entre nós é necessária. DESCANSE em toda a sua glória.
A principal diferença entre o JWT e outros tokens arbitrários é a padronização do conteúdo do token. Outra abordagem recomendada é enviar o token JWT no cabeçalho Authorization
usando o esquema Bearer. O conteúdo do cabeçalho deve ficar assim:
Authorization: Bearer <token>
Implementação de segurança REST
Para que os serviços REST funcionem conforme o esperado, precisamos de uma abordagem de autorização ligeiramente diferente em comparação aos sites clássicos de várias páginas.
Em vez de acionar o processo de autenticação redirecionando para uma página de login quando um cliente solicita um recurso seguro, o servidor REST autentica todas as solicitações usando os dados disponíveis na própria solicitação, o token JWT nesse caso. Se tal autenticação falhar, o redirecionamento não faz sentido. A API REST simplesmente envia uma resposta de código HTTP 401 (não autorizado) e os clientes devem saber o que fazer; por exemplo, um navegador mostrará uma div dinâmica para permitir que o usuário forneça o nome de usuário e a senha.
Por outro lado, após uma autenticação bem-sucedida em sites clássicos de várias páginas, o usuário é redirecionado usando o código HTTP 301 (Movido permanentemente), geralmente para uma página inicial ou, melhor ainda, para a página que o usuário solicitou inicialmente que acionou o processo de autenticação. Com REST, novamente isso não faz sentido. Em vez disso, simplesmente continuaríamos com a execução da solicitação como se o recurso não estivesse seguro, retornaríamos o código HTTP 200 (OK) e o corpo de resposta esperado.
Exemplo de segurança do Spring
Agora, vamos ver como podemos implementar a API REST baseada em token JWT usando Java e Spring, enquanto tentamos reutilizar o comportamento padrão do Spring Security onde pudermos.
Como esperado, a estrutura Spring Security vem com muitas classes prontas para plug-in que lidam com mecanismos de autorização “antigos”: cookies de sessão, HTTP Basic e HTTP Digest. No entanto, falta o suporte nativo para JWT e precisamos colocar a mão na massa para fazê-lo funcionar. Para uma visão geral mais detalhada, você deve consultar a documentação oficial do Spring Security.
Agora, vamos começar com a definição usual do filtro Spring Security em web.xml
:
<filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
Observe que o nome do filtro Spring Security deve ser exatamente springSecurityFilterChain
para que o restante da configuração do Spring funcione imediatamente.
Em seguida vem a declaração XML dos beans Spring relacionados à segurança. Para simplificar o XML, definiremos o namespace padrão como security
adicionando xmlns="http://www.springframework.org/schema/security"
ao elemento XML raiz. O resto do XML fica assim:
<global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
- (1) Nesta linha, ativamos
@PreFilter
,@PreAuthorize
,@PostFilter
,@PostAuthorize
em quaisquer beans de mola no contexto. - (2) Definimos os endpoints de login e inscrição para ignorar a segurança; mesmo “anônimo” deve ser capaz de fazer essas duas operações.
- (3) Em seguida, definimos a cadeia de filtros aplicada a todas as solicitações enquanto adicionamos duas configurações importantes: Referência do ponto de entrada e definindo a criação da sessão como
stateless
(não queremos que a sessão seja criada para fins de segurança, pois estamos usando tokens para cada solicitação) . - (4) Não precisamos de proteção
csrf
porque nossos tokens são imunes a ela. - (5) Em seguida, conectamos nosso filtro de autenticação especial na cadeia de filtros predefinidos do Spring, logo antes do filtro de login do formulário.
- (6) Este bean é a declaração do nosso filtro de autenticação; como ele está estendendo o
AbstractAuthenticationProcessingFilter
do Spring, precisamos declará-lo em XML para conectar suas propriedades (a ligação automática não funciona aqui). Explicaremos mais tarde o que o filtro faz. - (7) O manipulador de sucesso padrão de
AbstractAuthenticationProcessingFilter
não é bom o suficiente para fins de REST porque redireciona o usuário para uma página de sucesso; é por isso que estabelecemos o nosso aqui. - (8) A declaração do provedor criada pelo
authenticationManager
é usada pelo nosso filtro para autenticar os usuários.
Agora vamos ver como implementamos as classes específicas declaradas no XML acima. Observe que o Spring irá conectá-los para nós. Começamos pelos mais simples.

RestAuthenticationEntryPoint.java
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }
Conforme explicado acima, essa classe apenas retorna o código HTTP 401 (não autorizado) quando a autenticação falha, substituindo o redirecionamento padrão do Spring.
JwtAuthenticationSuccessHandler.java
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }
Essa substituição simples remove o comportamento padrão de uma autenticação bem-sucedida (redirecionamento para página inicial ou qualquer outra página solicitada pelo usuário). Se você está se perguntando por que não precisamos substituir o AuthenticationFailureHandler
, é porque a implementação padrão não redirecionará para nenhum lugar se seu URL de redirecionamento não estiver definido, portanto, evitamos definir o URL, o que é bom o suficiente.
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }
Essa classe é o ponto de entrada do nosso processo de autenticação JWT; o filtro extrai o token JWT dos cabeçalhos de solicitação e delega a autenticação ao AuthenticationManager
injetado. Se o token não for encontrado, será lançada uma exceção que interrompe o processamento da solicitação. Também precisamos de uma substituição para uma autenticação bem-sucedida porque o fluxo padrão do Spring interromperia a cadeia de filtros e prosseguiria com um redirecionamento. Lembre-se de que precisamos que a cadeia seja executada totalmente, incluindo a geração da resposta, conforme explicado acima.
JwtAuthenticationProvider.java
public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }
Nesta classe, estamos usando o AuthenticationManager
padrão do Spring, mas o injetamos com nosso próprio AuthenticationProvider
que faz o processo de autenticação real. Para implementar isso, estendemos o AbstractUserDetailsAuthenticationProvider
, que exige que apenas retornemos UserDetails
com base na solicitação de autenticação, no nosso caso, o token JWT encapsulado na classe JwtAuthenticationToken
. Se o token não for válido, lançamos uma exceção. No entanto, se for válido e a descriptografia por JwtUtil
for bem-sucedida, extraímos os detalhes do usuário (veremos exatamente como na classe JwtUtil
), sem acessar o banco de dados. Todas as informações sobre o usuário, incluindo suas funções, estão contidas no próprio token.
JwtUtil.java
public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }
Por fim, a classe JwtUtil
é responsável por analisar o token no objeto User
e gerar o token a partir do objeto User
. É simples, pois usa a biblioteca jjwt
para fazer todo o trabalho do JWT. Em nosso exemplo, simplesmente armazenamos o nome de usuário, o ID do usuário e as funções do usuário no token. Também poderíamos armazenar coisas mais arbitrárias e adicionar mais recursos de segurança, como a expiração do token. A análise do token é usada no AuthenticationProvider
conforme mostrado acima. O método generateToken()
é chamado a partir dos serviços REST de login e inscrição, que não são seguros e não acionarão nenhuma verificação de segurança nem exigirão que um token esteja presente na solicitação. Ao final, gera o token que será devolvido aos clientes, com base no usuário.
Conclusão
Embora as abordagens de segurança padronizadas e antigas (cookie de sessão, HTTP Basic e HTTP Digest) também funcionem com serviços REST, todas elas têm problemas que seria bom evitar usando um padrão melhor. O JWT chega bem a tempo de salvar o dia e, o mais importante, está muito perto de se tornar um padrão da IETF.
A principal força do JWT é lidar com a autenticação do usuário de maneira sem estado e, portanto, escalável, mantendo tudo seguro com padrões de criptografia atualizados. Armazenar declarações (funções de usuário e permissões) no próprio token cria enormes benefícios em arquiteturas de sistema distribuído em que o servidor que emite a solicitação não tem acesso à fonte de dados de autenticação.