Como fazer autenticação JWT com um SPA Angular 6

Publicados: 2022-03-11

Hoje veremos como é fácil integrar a autenticação de token da Web JSON (JWT) em seu aplicativo de página única (SPA) Angular 6 (ou posterior). Vamos começar com um pouco de fundo.

O que são tokens da Web JSON e por que usá-los?

A resposta mais fácil e concisa aqui é que eles são convenientes, compactos e seguros. Vejamos essas alegações em detalhes:

  1. Conveniente : Usar um JWT para autenticação no back-end uma vez logado requer a configuração de um cabeçalho HTTP, uma tarefa que pode ser facilmente automatizada por meio de uma função ou subclasse, como veremos mais adiante.
  2. Compacto : Um token é simplesmente uma string codificada em base64, contendo alguns campos de cabeçalho e uma carga útil, se necessário. O JWT total geralmente é inferior a 200 bytes, mesmo se assinado.
  3. Seguro : Embora não seja obrigatório, um ótimo recurso de segurança do JWT é que os tokens podem ser assinados usando criptografia de par de chaves pública/privada RSA ou criptografia HMAC usando um segredo compartilhado. Isso garante a origem e a validade de um token.

O que tudo isso se resume é que você tem uma maneira segura e eficiente de autenticar usuários e, em seguida, verificar chamadas para seus terminais de API sem precisar analisar nenhuma estrutura de dados nem implementar sua própria criptografia.

Teoria de Aplicação

Fluxo de dados típico para autenticação e uso JWT entre sistemas front-end e back-end

Então, com um pouco de experiência, agora podemos mergulhar em como isso funcionaria em um aplicativo real. Para este exemplo, vou supor que temos um servidor Node.js hospedando nossa API e estamos desenvolvendo uma lista de tarefas do SPA usando Angular 6. Vamos também trabalhar com essa estrutura de API:

  • /authPOST (postar nome de usuário e senha para autenticar e receber de volta um JWT)
  • /todosGET (retorna uma lista de itens da lista de tarefas para o usuário)
  • /todos/{id}GET (retorna um item específico da lista de tarefas)
  • /usersGET (retorna uma lista de usuários)

Vamos passar pela criação deste aplicativo simples em breve, mas por enquanto, vamos nos concentrar na interação em teoria. Temos uma página de login simples, onde o usuário pode digitar seu nome de usuário e senha. Quando o formulário é enviado, ele envia essas informações para o ponto de extremidade /auth . O servidor Node pode então autenticar o usuário da maneira que for apropriada (pesquisa de banco de dados, consulta de outro serviço da Web etc.), mas, em última análise, o endpoint precisa retornar um JWT.

O JWT para este exemplo conterá algumas declarações reservadas e algumas declarações privadas . As declarações reservadas são simplesmente pares de valores-chave recomendados pelo JWT comumente usados ​​para autenticação, enquanto as declarações privadas são pares de valores-chave aplicáveis ​​apenas ao nosso aplicativo:

Reivindicações Reservadas

  • iss : Emissor deste token. Normalmente, o FQDN do servidor, mas pode ser qualquer coisa, desde que o aplicativo cliente saiba como esperá-lo.
  • exp : Data e hora de expiração deste token. Isso é em segundos desde a meia-noite de 01 de janeiro de 1970 GMT (horário Unix).
  • nbf : Não é válido antes do carimbo de data/hora. Não é usado com frequência, mas fornece um limite inferior para a janela de validade. Mesmo formato que exp .

Reivindicações particulares

  • uid : ID do usuário logado.
  • role : Função atribuída ao usuário logado.

Nossas informações serão codificadas em base64 e assinadas usando HMAC com a chave compartilhada todo-app-super-shared-secret . Abaixo está um exemplo de como o JWT se parece:

 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b2RvYXBpIiwibmJmIjoxNDk4MTE3NjQyLCJleHAiOjE0OTgxMjEyNDIsInVpZCI6MSwicm9sZSI6ImFkbWluIn0.ZDz_1vcIlnZz64nSM28yA1s-4c_iw3Z2ZtP-SgcYRPQ

Essa string é tudo o que precisamos para ter certeza de que temos um login válido, para saber qual usuário está conectado e até mesmo para saber qual(is) função(ões) o usuário possui.

A maioria das bibliotecas e aplicativos armazenam esse JWT em localStorage ou sessionStorage para fácil recuperação, mas isso é apenas uma prática comum. O que você faz com o token depende de você, desde que possa fornecê-lo para futuras chamadas de API.

Agora, sempre que o SPA quiser fazer uma chamada para qualquer um dos endpoints de API protegidos, basta enviar o token no cabeçalho HTTP de Authorization .

 Authorization: Bearer {JWT Token}

Nota : Mais uma vez, isso é simplesmente uma prática comum. O JWT não prescreve nenhum método específico para se enviar ao servidor. Você também pode anexá-lo ao URL ou enviá-lo em um cookie.

Depois que o servidor recebe o JWT, ele pode decodificá-lo, garantir a consistência usando o segredo compartilhado HMAC e verificar a expiração usando os campos exp e nbf . Também poderia usar o campo iss para garantir que era a parte emissora original deste JWT.

Uma vez que o servidor esteja satisfeito com a validade do token, as informações armazenadas dentro do JWT podem ser usadas. Por exemplo, o uid que incluímos nos fornece o ID do usuário que está fazendo a solicitação. Para este exemplo específico, também incluímos o campo de role , que nos permite tomar decisões sobre se o usuário deve ou não acessar um determinado endpoint. (Se você confia nessas informações, ou se deseja fazer uma pesquisa no banco de dados, depende do nível de segurança necessário.)

 function getTodos(jwtString) { var token = JWTDecode(jwtstring); if( Date.now() < token.nbf*1000) { throw new Error('Token not yet valid'); } if( Date.now() > token.exp*1000) { throw new Error('Token has expired'); } if( token.iss != 'todoapi') { throw new Error('Token not issued here'); } var userID = token.uid; var todos = loadUserTodosFromDB(userID); return JSON.stringify(todos); }

Vamos construir um aplicativo Todo Simples

Para acompanhar, você precisará ter uma versão recente do Node.js (6.x ou posterior), npm (3.x ou posterior) e angular-cli instalados. Se você precisar instalar o Node.js, que inclui o npm, siga as instruções aqui. Depois angular-cli pode ser instalado usando npm (ou yarn , se você o instalou):

 # installation using npm npm install -g @angular/cli # installation using yarn yarn global add @angular/cli

Não entrarei em detalhes sobre o clichê do Angular 6 que usaremos aqui, mas para a próxima etapa, criei um repositório Github para conter um pequeno aplicativo de tarefas para ilustrar a simplicidade de adicionar autenticação JWT ao seu aplicativo. Basta cloná-lo usando o seguinte:

 git clone https://github.com/sschocke/angular-jwt-todo.git cd angular-jwt-todo git checkout pre-jwt

O comando git checkout pre-jwt alterna para uma versão nomeada em que o JWT não foi implementado.

Deve haver duas pastas dentro chamadas server e client . O servidor é um servidor Node API que hospedará nossa API básica. O cliente é nosso aplicativo Angular 6.

O servidor de API do nó

Para começar, instale as dependências e inicie o servidor de API.

 cd server # installation using npm npm install # or installation using yarn yarn node app.js

Você deve conseguir seguir esses links e obter uma representação JSON dos dados. Por enquanto, até que tenhamos autenticação, codificamos o endpoint /todos para retornar as tarefas para userID=1 :

  • http://localhost:4000: página de teste para ver se o servidor Node está em execução
  • http://localhost:4000/api/users: Retorna a lista de usuários no sistema
  • http://localhost:4000/api/todos: Retorna a lista de tarefas para userID=1

O aplicativo angular

Para começar com o aplicativo cliente, também precisamos instalar as dependências e iniciar o servidor dev.

 cd client # using npm npm install npm start # using yarn yarn yarn start

Nota : Dependendo da velocidade da sua linha, pode demorar um pouco para baixar todas as dependências.

Se tudo estiver indo bem, agora você deve ver algo assim ao navegar para http://localhost:4200:

A versão não habilitada para JWT do nosso aplicativo Angular Todo List.

Adicionando autenticação via JWT

Para adicionar suporte à autenticação JWT, usaremos algumas bibliotecas padrão disponíveis para simplificar. Você pode, é claro, renunciar a essas conveniências e implementar tudo sozinho, mas isso está além do nosso escopo aqui.

Primeiro, vamos instalar uma biblioteca no lado do cliente. Ele é desenvolvido e mantido pelo Auth0, que é uma biblioteca que permite adicionar autenticação baseada em nuvem a um site. A utilização da biblioteca em si não requer que você use seus serviços.

 cd client # installation using npm npm install @auth0/angular-jwt # installation using yarn yarn add @auth0/angular-jwt

Chegaremos ao código em um segundo, mas enquanto estamos nisso, vamos configurar o lado do servidor também. Usaremos as bibliotecas body-parser , jsonwebtoken e express-jwt para fazer o Node entender os corpos JSON POST e JWTs.

 cd server # installation using npm npm install body-parser jsonwebtoken express-jwt # installation using yarn yarn add body-parser jsonwebtoken express-jwt

Endpoint da API para autenticação

Primeiro, precisamos de uma maneira de autenticar os usuários antes de fornecer um token. Para nossa demonstração simples, vamos apenas configurar um endpoint de autenticação fixo com um nome de usuário e senha codificados. Isso pode ser tão simples ou tão complexo quanto sua aplicação requer. O importante é enviar de volta um JWT.

Em server/app.js adicione uma entrada abaixo das outras linhas require da seguinte forma:

 const bodyParser = require('body-parser'); const jwt = require('jsonwebtoken'); const expressJwt = require('express-jwt');

Bem como o seguinte:

 app.use(bodyParser.json()); app.post('/api/auth', function(req, res) { const body = req.body; const user = USERS.find(user => user.username == body.username); if(!user || body.password != 'todo') return res.sendStatus(401); var token = jwt.sign({userID: user.id}, 'todo-app-super-shared-secret', {expiresIn: '2h'}); res.send({token}); });

Isso é principalmente código JavaScript básico. Obtemos o corpo JSON que foi passado para o endpoint /auth , encontramos um usuário que corresponde a esse nome de usuário, verificamos se temos um usuário e a senha corresponde e retornamos um erro 401 Unauthorized HTTP, caso contrário.

A parte importante é a geração do token, e vamos decompô-la por seus três parâmetros. A sintaxe para sign é a seguinte: jwt.sign(payload, secretOrPrivateKey, [options, callback]) , onde:

  • payload é um literal de objeto de pares chave-valor que você gostaria de codificar em seu token. Essas informações podem ser decodificadas do token por qualquer pessoa que tenha a chave de descriptografia. Em nosso exemplo, codificamos o user.id para que, quando recebermos o token novamente no back-end para autenticação, saibamos com qual usuário estamos lidando.
  • secretOrPrivateKey é uma chave secreta compartilhada de criptografia HMAC — foi isso que usamos em nosso aplicativo, para simplificar — ou uma chave privada de criptografia RSA/ECDSA.
  • options representa uma variedade de opções que podem ser passadas ao codificador na forma de pares chave-valor. Normalmente, pelo menos especificamos expiresIn (torna-se uma declaração reservada exp ) e issuer (reivindicação reservada iss ) para que um token não seja válido para sempre e o servidor possa verificar se de fato emitiu o token originalmente.
  • callback é uma função a ser chamada após a codificação ser feita, caso se deseje manipular a codificação do token de forma assíncrona.

(Você também pode ler mais detalhes sobre options e como usar criptografia de chave pública em vez de uma chave secreta compartilhada.)

Integração Angular 6 JWT

Para fazer o Angular 6 funcionar com nosso JWT é bem simples usando angular-jwt . Basta adicionar o seguinte a client/src/app/app.modules.ts :

 import { JwtModule } from '@auth0/angular-jwt'; // ... export function tokenGetter() { return localStorage.getItem('access_token'); } @NgModule({ // ... imports: [ BrowserModule, AppRoutingModule, HttpClientModule, FormsModule, // Add this import here JwtModule.forRoot({ config: { tokenGetter: tokenGetter, whitelistedDomains: ['localhost:4000'], blacklistedRoutes: ['localhost:4000/api/auth'] } }) ], // ... }

Isso é basicamente tudo o que é necessário. Claro, temos mais algum código para adicionar para fazer a autenticação inicial, mas a biblioteca angular-jwt se encarrega de enviar o token junto com cada solicitação HTTP.

  • A função tokenGetter() faz exatamente o que diz, mas como ela é implementada depende inteiramente de você. Optamos por retornar o token que salvamos em localStorage . É claro que você está livre para fornecer qualquer outro método que desejar, desde que ele retorne a string codificada pelo token da Web JSON .
  • A opção whiteListedDomains existe para que você possa restringir para quais domínios o JWT é enviado, para que as APIs públicas também não recebam seu JWT.
  • A opção blackListedRoutes permite especificar rotas específicas que não devem receber o JWT, mesmo que estejam em um domínio na lista de permissões. Por exemplo, o ponto de extremidade de autenticação não precisa recebê-lo porque não faz sentido: o token normalmente é nulo quando é chamado de qualquer maneira.

Fazendo tudo funcionar em conjunto

Neste ponto, temos uma maneira de gerar um JWT para um determinado usuário usando o endpoint /auth em nossa API, e temos o encanamento feito em Angular para enviar um JWT a cada solicitação HTTP. Ótimo, mas você pode apontar que absolutamente nada mudou para o usuário. E você estaria correto. Ainda podemos navegar para todas as páginas em nosso aplicativo e podemos chamar qualquer endpoint de API sem enviar um JWT. Não é bom!

Precisamos atualizar nosso aplicativo cliente para nos preocuparmos com quem está logado e também atualizar nossa API para exigir um JWT. Vamos começar.

Vamos precisar de um novo componente Angular para fazer login. Por uma questão de brevidade, manterei isso o mais simples possível. Também precisaremos de um serviço que lide com todos os nossos requisitos de autenticação e um Angular Guard para proteger as rotas que não devem estar acessíveis antes do login. Faremos o seguinte no contexto do aplicativo cliente.

 cd client ng g component login --spec=false --inline-style ng g service auth --flat --spec=false ng g guard auth --flat --spec=false

Isso deve ter gerado quatro novos arquivos na pasta do client :

 src/app/login/login.component.html src/app/login/login.component.ts src/app/auth.service.ts src/app/auth.guard.ts

Em seguida, precisamos fornecer o serviço de autenticação e proteção para nosso aplicativo. Atualize client/src/app/app.modules.ts :

 import { AuthService } from './auth.service'; import { AuthGuard } from './auth.guard'; // ... providers: [ TodoService, UserService, AuthService, AuthGuard ],

E então atualize o roteamento em client/src/app/app-routing.modules.ts para fazer uso da proteção de autenticação e fornecer uma rota para o componente de login.

 // ... import { LoginComponent } from './login/login.component'; import { AuthGuard } from './auth.guard'; const routes: Routes = [ { path: 'todos', component: TodoListComponent, canActivate: [AuthGuard] }, { path: 'users', component: UserListComponent, canActivate: [AuthGuard] }, { path: 'login', component: LoginComponent}, // ...

Por fim, atualize client/src/app/auth.guard.ts com o seguinte conteúdo:

 import { Injectable } from '@angular/core'; import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; @Injectable() export class AuthGuard implements CanActivate { constructor(private router: Router) { } canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) { if (localStorage.getItem('access_token')) { return true; } this.router.navigate(['login']); return false; } }

Para nosso aplicativo de demonstração, estamos simplesmente verificando a existência de um JWT no armazenamento local. Em aplicativos do mundo real, você decodificaria o token e verificaria sua validade, expiração etc. Por exemplo, você poderia usar JwtHelperService para isso.

Neste ponto, nosso aplicativo Angular agora sempre redirecionará você para a página de login, pois não temos como fazer login. Vamos corrigir isso, começando com o serviço de autenticação em client/src/app/auth.service.ts :

 import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @Injectable() export class AuthService { constructor(private http: HttpClient) { } login(username: string, password: string): Observable<boolean> { return this.http.post<{token: string}>('/api/auth', {username: username, password: password}) .pipe( map(result => { localStorage.setItem('access_token', result.token); return true; }) ); } logout() { localStorage.removeItem('access_token'); } public get loggedIn(): boolean { return (localStorage.getItem('access_token') !== null); } }

Nosso serviço de autenticação tem apenas duas funções, login e logout :

  • login POST é o nome de username e password fornecidos para nosso back-end e define o access_token em localStorage se ele receber um de volta. Por uma questão de simplicidade, não há tratamento de erros aqui.
  • logout simplesmente limpa access_token de localStorage , exigindo que um novo token seja adquirido antes que qualquer outra coisa possa ser acessada novamente.
  • loggedIn é uma propriedade booleana que podemos usar rapidamente para determinar se o usuário está logado ou não.

E por último, o componente de login. Eles não têm relação com o trabalho real com JWT, portanto, sinta-se à vontade para copiar e colar em client/src/app/login/login.components.html :

 <h4 *ngIf="error">{{error}}</h4> <form (ngSubmit)="submit()"> <div class="form-group col-3"> <label for="username">Username</label> <input type="text" name="username" class="form-control" [(ngModel)]="username" /> </div> <div class="form-group col-3"> <label for="password">Password</label> <input type="password" name="password" class="form-control" [(ngModel)]="password" /> </div> <div class="form-group col-3"> <button class="btn btn-primary" type="submit">Login</button> </div> </form>

E client/src/app/login/login.components.ts precisará:

 import { Component, OnInit } from '@angular/core'; import { AuthService } from '../auth.service'; import { Router } from '@angular/router'; import { first } from 'rxjs/operators'; @Component({ selector: 'app-login', templateUrl: './login.component.html' }) export class LoginComponent { public username: string; public password: string; public error: string; constructor(private auth: AuthService, private router: Router) { } public submit() { this.auth.login(this.username, this.password) .pipe(first()) .subscribe( result => this.router.navigate(['todos']), err => this.error = 'Could not authenticate' ); } }

Voila, nosso exemplo de login Angular 6:

A tela de login do nosso aplicativo Angular Todo List de amostra.

Nesta fase, devemos ser capazes de fazer login (usando jemma , paul ou sebastian com a senha todo ) e ver todas as telas novamente. Mas nosso aplicativo mostra os mesmos cabeçalhos de navegação e nenhuma maneira de sair, independentemente do estado atual. Vamos corrigir isso antes de prosseguirmos para corrigir nossa API.

Em client/src/app/app.component.ts , substitua todo o arquivo pelo seguinte:

 import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { AuthService } from './auth.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { constructor(private auth: AuthService, private router: Router) { } logout() { this.auth.logout(); this.router.navigate(['login']); } }

E para client/src/app/app.component.html substitua a seção <nav> pelo seguinte:

 <nav class="nav nav-pills"> <a class="nav-link" routerLink="todos" routerLinkActive="active" *ngIf="auth.loggedIn">Todo List</a> <a class="nav-link" routerLink="users" routerLinkActive="active" *ngIf="auth.loggedIn">Users</a> <a class="nav-link" routerLink="login" routerLinkActive="active" *ngIf="!auth.loggedIn">Login</a> <a class="nav-link" (click)="logout()" href="#" *ngIf="auth.loggedIn">Logout</a> </nav>

Tornamos nossa navegação sensível ao contexto de que ela deve exibir apenas determinados itens, dependendo de o usuário estar logado ou não. auth.loggedIn pode, é claro, ser usado em qualquer lugar onde você possa importar o serviço de autenticação.

Protegendo a API

Você pode estar pensando, isso é ótimo… tudo parece estar funcionando maravilhosamente . Mas tente fazer login com todos os três nomes de usuário diferentes e você notará algo: todos eles retornam a mesma lista de tarefas. Se dermos uma olhada em nosso servidor de API, podemos ver que cada usuário, de fato, tem sua própria lista de itens, então o que está acontecendo?

Bem, lembre-se que quando começamos, codificamos nosso ponto de extremidade da API /todos para sempre retornar a lista de tarefas para userID=1 . Isso ocorreu porque não tínhamos como saber quem era o usuário conectado no momento.

Agora sim, então vamos ver como é fácil proteger nossos endpoints e usar as informações codificadas no JWT para fornecer a identidade de usuário necessária. Inicialmente, adicione esta linha ao seu arquivo server/app.js logo abaixo da última chamada app.use() :

 app.use(expressJwt({secret: 'todo-app-super-shared-secret'}).unless({path: ['/api/auth']}));

Usamos o middleware express-jwt , informamos qual é o segredo compartilhado e especificamos uma matriz de caminhos para os quais não deve exigir um JWT. E é isso. Não há necessidade de tocar em todos os endpoints, criar instruções if por toda parte ou qualquer coisa.

Internamente, o middleware está fazendo algumas suposições. Por exemplo, ele assume que o cabeçalho Authorization HTTP está seguindo o padrão JWT comum de Bearer {token} . (A biblioteca tem muitas opções para personalizar como funciona, se não for o caso. Consulte Express-jwt Usage para obter mais detalhes.)

Nosso segundo objetivo é usar as informações codificadas do JWT para descobrir quem está fazendo a chamada. Mais uma vez, express-jwt vem em socorro. Como parte da leitura e verificação do token, ele define a carga útil codificada que enviamos no processo de assinatura para a variável req.user no Express. Podemos então usá-lo para acessar imediatamente qualquer uma das variáveis ​​que armazenamos. No nosso caso, configuramos userID igual ao ID do usuário autenticado e, como tal, podemos usá-lo diretamente como req.user.userID .

Atualize server/app.js novamente e altere o endpoint /todos para ler da seguinte forma:

 res.send(getTodos(req.user.userID)); 

Nosso aplicativo Angular Todo List aproveitando o JWT para mostrar a lista de tarefas do usuário conectado, em vez da que havíamos codificado anteriormente.

E é isso. Nossa API agora está protegida contra acesso não autorizado e podemos determinar com segurança quem é nosso usuário autenticado em qualquer endpoint. Nosso aplicativo cliente também tem um processo de autenticação simples, e quaisquer serviços HTTP que escrevermos que chamem nosso endpoint de API terão automaticamente um token de autenticação anexado.

Se você clonou o repositório do Github e simplesmente quer ver o resultado final em ação, você pode conferir o código em sua forma final usando:

 git checkout with-jwt

Espero que você tenha achado este passo a passo valioso para adicionar a autenticação JWT aos seus próprios aplicativos Angular. Obrigado por ler!

Relacionado: Tutorial do JSON Web Token: um exemplo em Laravel e AngularJS