Autenticação JWT com Angular e Django
Publicado em
Quase todo sistema que opera na internet e armazena dados de usuário hoje em dia depende de uma camada de autenticação. E com o crescimento do modelo de API`s e separação do frontend do backend a implementação desta camada cresceu em complexidade.
Esta postagem tem como foco mostrar o caminho das pedras em uma stack de frontend Angular 6 e backend Django 2 utilizando o modelo de separação discutido na postagem “Separando o Frontend do Backend com Angular e Django”.
Os repositórios com o código desta postagem são:
- Backend: https://github.com/humrochagf/post-jwt-backend
- Frontend: https://github.com/humrochagf/post-jwt-frontend
Autenticação
Existem diversas formas de se autenticar em um sistema. A mais famosa e adotada pela internet como padrão é o modelo de usuário e senha, onde apresentamos nosso identificador (id, nome de usuário, email ou semelhante) e um segredo compartilhado com o serviço que queremos acessar, que após sua validação, nos é devolvido um token que funciona como um bilhete de acesso aos nossos recursos pessoais naquele serviço.
Quando implementamos sistemas monolíticos onde o backend e o frontend estão juntos esta manutenção deste token acontece de forma automática pela maioria das bibliotecas de autenticação disponíveis por aí. No máximo decidimos coisas como tempo de validade da sessão ou onde o token será armazenado trocando uma variável de configuração ou outra.
Porém, ao separarmos o backend do frontend esta gestão fica um pouco mais por nossa conta as decisões sobre o que fazer e usar para isso deve ser tomadas por nós durante a implementação. E existem enumeras formas de se fazer esta gestão de acesso, uma delas é simplesmente utilizar o token padrão devolvido pelo sistema, mas neste caso como eles estão separados perdemos a possibilidade de atualizar a validade deste token nos obrigando a colocar novamente a senha para obter um novo acesso.
Para nos auxiliar com estes problemas utilizaremos o JWT.
JWT
JWT ou JSON Web Token nada mais é que um objeto JSON definido na RFC 7519 para realizar transferência informação de permissões de acesso entre duas pontas. Ele é codificado e assinado e possuí o seguinte formato:
header.payload.signature
No header (cabeçalho) ficam os dados do token, que informam seu tipo e o algoritmo utilizado em sua assinatura:
{
"alg": "HS256",
"typ": "JWT"
}
No payload (carga) ficam os dados do usuário e alguns metadados como a expiração do token:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Finalmente na signature (assinatura) os dados de header e payload codificados em base 64 e unidos por .
(ponto) para serem assinados usando o algoritmo definido no header:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
Com a assinatura é possível verificar se o token não foi alterado no caminho, garantindo sua integridade. Com ela também é possível confirmar a autenticidade de sua fonte.
Estes três blocos unidos por .
(ponto) cada um codificado em base 64 compõem o JWT Token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.PcmVIPbcZl9j7qFzXRAeSyhtuBnHQNMuLHsaG5l804A
Após confirmar os dados de usuário e receber o JWT ele deve ser armazenado, normalmente em local storage para ser utilizado nas requisições autenticadas usando o esquema de cabeçalho JWT
:
Authorization: JWT <token>
Este é um mecanismo de autenticação que não guarda estado, ou seja, não requer armazenamento de dados de sessão no banco de dados do servidor.
Agora, com todo este conhecimento é hora da implementação!!!
E para isso, utilizaremos como base a aplicação desenvolvida na postagem anterior “Separando o Frontend do Backend com Angular e Django”.
Backend
A primeira coisa que faremos é instalar o pacote djangorestframework-jwt
:
$ pip install djangorestframework-jwt
Em seguida iremos adicionar suas configurações no settings.py
:
from datetime import timedelta
# REST Framework settings
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
'NON_FIELD_ERRORS_KEY': 'global',
}
# JWT settings
JWT_AUTH = {
'JWT_ALLOW_REFRESH': True,
'JWT_EXPIRATION_DELTA': timedelta(days=2),
}
Seguido das rotas de login e atualização de token jwt em urls.py
:
from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token
urlpatterns = [
...
path('auth/login/', obtain_jwt_token),
path('auth/refresh-token/', refresh_jwt_token),
]
Como foi alterada a permissão padrão do sistema no settings.py
para IsAuthenticated
ao tentar acessar a lista de compra nos deparamos com a seguinte mensagem:
Agora que o acesso está sendo restrito a usuários logados chegou a hora de implementar a autenticação no frontend.
Frontend
A primeira tarefa a ser executada no frontend é a de mover o conteúdo de app.component.ts
para um componente separado nomeado como list.component.ts
e adicionar rotas na aplicação, criando um segundo componente para o login:
// list.component.ts
import { Component, OnInit } from '@angular/core';
import { ApiService } from './api.service';
import { ShoppingItem } from './shopping-item.interface';
@Component({
selector: 'app-list',
template: `
<div style="text-align:center">
<h1>
Lista de compras
</h1>
</div>
<ul>
<li *ngFor="let item of items">
<h2>{{ item.quantity }}x {{ item.name }}
<button (click)="delete(item.id)">x</button></h2>
</li>
</ul>
<input #itemQuantity type='text' placeholder='Qtd'>
<input #itemName type='text' placeholder='Name'>
<button (click)="add(itemName.value, itemQuantity.value)">Add</button>
<p>{{ error?.message }}</p>
<p *ngIf="error">{{ error?.error | json }}</p>
<button (click)="logout()">deslogar</button>
`
})
export class ListComponent implements OnInit {
items: ShoppingItem[];
error: any;
constructor(private api: ApiService) { }
ngOnInit() {
this.api.getShoppingItems().subscribe(
(items: ShoppingItem[]) => this.items = items,
(error: any) => this.error = error
);
}
add(itemName: string, itemQuantity: number) {
this.api.createShoppingItem(itemName, itemQuantity).subscribe(
(item: ShoppingItem) => this.items.push(item),
(error: any) => this.error = error
);
}
delete(id: number) {
this.api.deleteShoppingItem(id).subscribe(
(success: any) => this.items.splice(
this.items.findIndex(item => item.id === id)
),
(error: any) => this.error = error
);
}
}
// app.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<router-outlet></router-outlet>
`
})
export class AppComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}
// login.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-login',
template: `
<div style="text-align:center">
<h1>
Login
</h1>
</div>
<input #username type='text' placeholder='username'>
<input #password type='password' placeholder='password'>
<button (click)="login(username.value, password.value)">login</button>
<p>{{ error?.message }}</p>
<p *ngIf="error">{{ error?.error | json }}</p>
`
})
export class LoginComponent implements OnInit {
error: any;
constructor() { }
ngOnInit() {
}
login(username: string, password: string) {
// TODO: call login
}
}
// signup.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-signup',
template: `
<div style="text-align:center">
<h1>
Cadastrar
</h1>
</div>
<input #username type='text' placeholder='username'>
<input #email type='text' placeholder='email'>
<input #password1 type='password' placeholder='password1'>
<input #password2 type='password' placeholder='password2'>
<button (click)="signup(username.value, email.value, password1.value, password2.value)">cadastrar</button>
<p>{{ error?.message }}</p>
<p *ngIf="error">{{ error?.error | json }}</p>
`
})
export class SignupComponent implements OnInit {
error: any;
constructor() { }
ngOnInit() {
}
signup(username: string, email: string, password1: string, password2: string) {
// TODO: call signup
}
}
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ListComponent } from './list.component';
import { LoginComponent } from './login.component';
import { SignupComponent } from './signup.component';
const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'login', component: LoginComponent },
{ path: 'signup', component: SignupComponent },
{ path: 'list', component: ListComponent },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
// app.module.ts
...
import { AppRoutingModule } from './app-routing.module';
import { ListComponent } from './list.component';
import { LoginComponent } from './login.component';
import { SignupComponent } from './signup.component';
...
@NgModule({
declarations: [
AppComponent,
ListComponent,
LoginComponent,
SignupComponent,
],
imports: [
...
AppRoutingModule,
],
...
})
export class AppModule { }
Para auxiliar na implementação do frontend utilizaremos duas bibliotecas:
$ npm install -s moment
$ npm install -s jwt-decode
$ npm install -s @types/jwt-decode
A biblioteca moment facilitará o trabalho com tempo, já que precisamos controlar a expiração do token e sua renovação enquanto a biblioteca jwt-decode cuidará do token em si.
Com as bibliotecas em mãos iniciaremos pelo service de autênticação:
// auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { CanActivate, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { tap, shareReplay } from 'rxjs/operators';
import * as jwtDecode from 'jwt-decode';
import * as moment from 'moment';
import { environment } from '../environments/environment';
@Injectable()
export class AuthService {
private apiRoot = 'http://localhost:8000/auth/';
constructor(private http: HttpClient) { }
private setSession(authResult) {
const token = authResult.token;
const payload = <JWTPayload> jwtDecode(token);
const expiresAt = moment.unix(payload.exp);
localStorage.setItem('token', authResult.token);
localStorage.setItem('expires_at', JSON.stringify(expiresAt.valueOf()));
}
get token(): string {
return localStorage.getItem('token');
}
login(username: string, password: string) {
return this.http.post(
this.apiRoot.concat('login/'),
{ username, password }
).pipe(
tap(response => this.setSession(response)),
shareReplay(),
);
}
signup(username: string, email: string, password1: string, password2: string) {
// TODO: implement signup
}
logout() {
localStorage.removeItem('token');
localStorage.removeItem('expires_at');
}
refreshToken() {
if (moment().isBetween(this.getExpiration().subtract(1, 'days'), this.getExpiration())) {
return this.http.post(
this.apiRoot.concat('refresh-token/'),
{ token: this.token }
).pipe(
tap(response => this.setSession(response)),
shareReplay(),
).subscribe();
}
}
getExpiration() {
const expiration = localStorage.getItem('expires_at');
const expiresAt = JSON.parse(expiration);
return moment(expiresAt);
}
isLoggedIn() {
return moment().isBefore(this.getExpiration());
}
isLoggedOut() {
return !this.isLoggedIn();
}
}
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = localStorage.getItem('token');
if (token) {
const cloned = req.clone({
headers: req.headers.set('Authorization', 'JWT '.concat(token))
});
return next.handle(cloned);
} else {
return next.handle(req);
}
}
}
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) { }
canActivate() {
if (this.authService.isLoggedIn()) {
this.authService.refreshToken();
return true;
} else {
this.authService.logout();
this.router.navigate(['login']);
return false;
}
}
}
interface JWTPayload {
user_id: number;
username: string;
email: string;
exp: number;
}
O service criado possuí 3 classes e 1 interface das quais suas funções são as seguintes:
A classe AuthService
é a responsável por no autenticar de fato no sistema. Ela implemanta as funções de login
, logout
e refreshToken
para fazer a manutenção da sessão no sistema, auxiliada das funções setSession
que salva a sessão em local storage, getExpiration
que realiza o cálculo de expiração para a função refreshToken
decidir se é hora de atualizar o token ou não, os pares isLoggedIn
e isLoggedOut
que são utilizados para verificar se o usuário está logado e o getter token
que retorna o JWT para ser utilizado nas requisições autenticadas.
A classe AuthInterceptor
implementa os interceptadores do Angular, que neste caso intercepta todas as requisições http realizadas e, caso o usuário esteja logado, injeta o cabeçalho Authorization JWT <token>
na requisição para realizar chamadas autenticadas na API.
A classe AuthGuard
serve como um escudo que impede o acesso de usuário não logado nas rotas em que ela for vinculada, se um usuário não logado tenta acessar determinada rota protegida por ela, o mesmo será redirecionado para a tela de login.
E por fim a interface JWTPayload
que serve somente para definirmos o formato do payload retornado no JWT dentro do typescript.
Após a criação do service vamos adicioná-lo na aplicação, definir as rotas protegidas e finalmente realizar a chamada de autenticação no componente de login:
// app.module.ts
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthService, AuthInterceptor, AuthGuard } from './auth.service';
@NgModule({
...
providers: [
...
AuthService,
AuthGuard,
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true,
},
],
})
export class AppModule { }
// app-routing.module.ts
import { AuthGuard } from './auth.service';
...
const routes: Routes = [
...
{ path: 'list', component: ListComponent, canActivate: [AuthGuard] },
];
// login.component.ts
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
...
export class LoginComponent implements OnInit {
error: any;
constructor(
private authService: AuthService,
private router: Router,
) { }
ngOnInit() {
}
login(username: string, password: string) {
this.authService.login(username, password).subscribe(
success => this.router.navigate(['list']),
error => this.error = error
);
}
}
Com a aplicação rodando agora podemos visualizar processo de login funcionando \o/:
Processo de Cadastro
Uma outra operação bastante comum ao lidar com autenticação é o processo de cadastro de novas contas no sistema. A biblioteca djangorestframework-jwt
não nos fornece esta funcionalidade pronta, para isso será preciso criar a própria view de cadastro. Porém existem duas bibliotecas que usadas em conjunto nos fornecem não somente esta funcionalidade como também suporte a validação de conta por email e cadastro e login com outros métodos como cadastro com contas de redes sociais (Github, Google entre outros).
Estas bibliotecas são django-rest-auth responsável por disponibilizar as de views de autênticação e cadastro. E django-allauth responsável pela estrutura de cadastro, validação de conta e autênticação social.
Para incluir o processo de cadastro em nossa aplicação vamos instalar as bibliotecas no backend:
$ pip install django-rest-auth django-allauth
Em seguida adicionamos as seguintes configurações no settings.py
:
INSTALLED_APPS = [
...,
'django.contrib.sites',
'allauth',
'allauth.account',
'rest_auth',
'rest_auth.registration',
]
# allauth
SITE_ID = 1
ACCOUNT_EMAIL_VERIFICATION = 'none'
# JWT settings
REST_USE_JWT = True
Desabilitamos o ACCOUNT_EMAIL_VERIFICATION
nesta postagem pois estamos focados nos fluxos que envolvem a manipulação do JWT Token no processo de autenticação, em aplicações no “mundo real” processos de verificação de conta são importantes para mitigar abusos em seu sistema.
A variável REST_USE_JWT
informa ao rest_auth
que utilizaremos o JWT ao invés do Token padrão.
Adicionadas as configurações é hora de configurar as rotas do backend em urls.py
:
urlpatterns = [
...,
path('auth/login/', obtain_jwt_token), # remova esta linha
path('auth/', include('rest_auth.urls')),
path('auth/signup/', include('rest_auth.registration.urls')),
path('auth/refresh-token/', refresh_jwt_token),
]
Nesta configuração removemos a rota auth/login/
pois ela já existe nas rotas do rest_auth
.
Um outro ponto interesante é que tanto o login quanto o cadastro no rest_auth
retornam junto com o JWT Token uma instância do usuário logado, o que pode lhe poupar uma requisição a sua api para recuperar os dados de usuário após essas operações.
Agora que temos a funcionalidade de cadastro pronta no backend, vamos às alterações de frontend:
// auth.service.ts
...
signup(username: string, email: string, password1: string, password2: string) {
return this.http.post(
this.apiRoot.concat('signup/'),
{ username, email, password1, password2 }
).pipe(
tap(response => this.setSession(response)),
shareReplay(),
);
}
...
// signup.component.ts
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
...
export class SignupComponent implements OnInit {
error: any;
constructor(
private authService: AuthService,
private router: Router,
) { }
ngOnInit() {
}
signup(username: string, email: string, password1: string, password2: string) {
this.authService.signup(username, email, password1, password2).subscribe(
success => this.router.navigate(['list']),
error => this.error = error
);
}
}
Com essa pequena alteração agora é possível realizar cadastros no sistema acessando o /signup
do seu frontend.
Conclusão
Já existem bibliotecas no Angular que implementam a autenticação usando JWT automaticamente, porém, compreender o funcionamento deste mecanismo que estará presente durante toda a vida útil da sua aplicação e ter a autonomia para fazer os ajustes necessários de acordo com suas necessidades faz com que uma implementação um pouco mais manual valha a pena o esforço.
Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional .