Login/Logout, Proteção de rotas e envio de tokens com Angular

Criar um login com Angular parece simples à primeira vista, mas o fluxo completo envolve storage, guardas de rotas e interceptors trabalhando juntos. Neste artigo, você vai montar de forma prática um login e um logout funcionais, protegendo rotas e enviando o token do usuário para a API automaticamente.

O que você precisa para um login com Angular

Antes de escrever código, vale entender as três peças que sustentam o fluxo. Cada uma resolve um problema diferente, e juntas formam a base de qualquer login com Angular.

Além disso, entender o papel de cada peça ajuda a depurar o login com Angular quando algo não se comporta como esperado.

Storage

O storage guarda localmente os dados do usuário autenticado, como o identificador, o token e o nome. Dessa forma, a aplicação consulta essas informações sempre que precisar, sem pedir um novo login a cada navegação.

Esses dados ficam visíveis no navegador, por isso a recomendação do exemplo original é usar alguma forma de criptografia quando possível. Mais adiante, você vai ver por que a simples conversão para Base64 não protege nada no login com Angular.

Route guards

Os route guards, ou guardas de rotas, verificam se quem acessa uma rota está autenticado ou não. Assim, o próprio roteador redireciona o usuário quando ele tenta entrar em uma página que não deveria ver.

Se você quiser se aprofundar no roteamento do framework, o artigo sobre rotas, guardas e navegação no Angular complementa bem este conteúdo.

Token interceptor

O token interceptor intercepta as chamadas HTTP feitas para a API que exige autenticação. Por exemplo, imagine que o endereço base da API é https://localhost:5001/v1.

Sempre que houver uma chamada para esse endereço, o interceptor adiciona o token do usuário no cabeçalho. Com isso, você não precisa repetir o token manualmente em cada requisição do projeto.

Preparando o projeto de login com Angular

Este tutorial de login com Angular parte do princípio de que você já sabe criar um projeto e conhece o básico do framework. Caso ainda não saiba, a documentação oficial é bastante completa e vale a consulta.

Com o projeto criado, a estrutura de pastas usada no exemplo fica assim, com páginas, serviços, guards, interceptors e interfaces separados:

Estrutura de pastas do projeto de login com Angular, com as pastas pages, services, guards, interceptors e interfaces
Organização de pastas usada ao longo do tutorial.

Estilizando com Angular Material

Para estilizar páginas, formulários e botões, o exemplo usa o Angular Material. No entanto, você pode escolher o framework visual de sua preferência sem nenhum impacto na lógica do login com Angular.

Para usar a mesma biblioteca, execute o comando abaixo no terminal. Em seguida, escolha um tema e confirme as opções sugeridas pelo instalador.

ng add @angular/material

Depois disso, importe no módulo principal app.module.ts os módulos do Material que a tela vai usar. Aproveite também para importar os módulos de formulários reativos e de chamadas HTTP, que o login vai precisar.

Arquivo app.module.ts importando os módulos do Angular Material, de formulários reativos e do HttpClient
Módulos importados no módulo principal da aplicação.

Criando os componentes do login com Angular

Agora você vai criar três páginas. A primeira é a Home, que o usuário só acessa quando está autenticado.

A segunda é a Login, que o usuário só acessa quando ainda não entrou no sistema. Por fim, a terceira é a Main, que funciona como layout base, com cabeçalho e área de conteúdo.

Use os comandos abaixo para gerar os componentes diretamente na pasta pages:

ng g c pages/login
ng g c pages/home
ng g c pages/shared/main
Componentes do login com Angular gerados pela CLI na pasta pages, incluindo login, home e o layout compartilhado
Componentes gerados pela Angular CLI no projeto de exemplo.

Você pode apagar os arquivos .spec.ts neste momento, porque servem para testes e não fazem parte do escopo do tutorial.

Configurando as rotas

Com os três componentes criados, adicione as rotas correspondentes no arquivo app-routing.module.ts. A primeira versão fica como a imagem abaixo, ainda sem nenhuma proteção.

Arquivo app-routing.module.ts com a rota de login e a rota raiz contendo a Home como rota filha do layout
Primeira versão das rotas, ainda sem guards.

Repare no truque usado nas rotas do login com Angular. A Home e todas as próximas páginas que precisarem do layout padrão e de autenticação viram rotas filhas do componente de layout.

Para que tudo funcione, apague o conteúdo do arquivo app.component.html e mantenha apenas o router-outlet. Afinal, ele é o responsável por renderizar o componente da rota ativa.

Arquivo app.component.html contendo apenas a tag router-outlet
O componente raiz passa a exibir somente o router-outlet.

Montando o layout principal

Em seguida, escreva o HTML e o CSS do layout padrão. O exemplo abaixo usa uma toolbar com o botão de logout, mas você pode desenhar o seu como preferir.

main.component.html

<p>
  <mat-toolbar color="primary">
    <span>Login example</span>
    <span class="toolbar-spacer"></span>
    <button mat-icon-button>
      <mat-icon>logout</mat-icon>
    </button>
  </mat-toolbar>
</p>

<mat-card-content>
  <router-outlet></router-outlet>
</mat-card-content>

main.component.scss

.toolbar-spacer {
  flex: 1 1 auto;
}

Observe que existe outro router-outlet dentro do layout. É justamente nesse ponto que o Angular carrega as rotas filhas do componente principal.

Para visualizar o resultado, rode ng serve --open e abra a rota raiz no navegador.

Nesse ponto, o layout do login com Angular já aparece, embora qualquer pessoa ainda consiga acessar a Home sem se autenticar.

Aplicação de login com Angular rodando no navegador, exibindo a toolbar do layout e o conteúdo da Home
Rota raiz exibindo o layout principal e a Home como filha.

Criando o serviço de usuário

Antes da tela, crie o serviço que concentra os métodos relacionados ao usuário. Ele é o coração do login com Angular, porque todos os outros componentes dependem dele.

Para isso, use o comando ng g service services/user.

Aproveite também para criar a interface que tipa os dados do usuário. Na pasta interfaces, crie o arquivo IUser.ts:

export interface IUser {
  id: string;
  email: string;
  password: string;
}

No arquivo user.service.ts, já ficam todos os métodos necessários para entrar e sair da aplicação:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { IUser } from '../interfaces/IUser';

const userApiUrl = environment.apiUrl + 'User';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  constructor(private httpClient: HttpClient,
              private router: Router) { }

  login(user: IUser): Observable<any> {
    // Versão real, chamando a API:
    // return this.httpClient.post<any>(userApiUrl + '/login', user).pipe(
    //   tap((response) => {
    //     if (!response.success) return;
    //     localStorage.setItem('token', btoa(JSON.stringify(response['token'])));
    //     localStorage.setItem('user', btoa(JSON.stringify(response['user'])));
    //     this.router.navigate(['']);
    //   }));

    // Versão simulada (mock), sem API
    return this.mockUserLogin(user).pipe(tap((response) => {
      if (!response.success) return;
      localStorage.setItem('token', btoa(JSON.stringify('TokenGeneratedByApi')));
      localStorage.setItem('user', btoa(JSON.stringify(user)));
      this.router.navigate(['']);
    }));
  }

  private mockUserLogin(user: IUser): Observable<any> {
    const mockResponse: any = {};
    if (user.email === 'hello@balta.io' && user.password === '123') {
      mockResponse.success = true;
      mockResponse.user = user;
      mockResponse.token = 'TokenGeneratedByApi';
      return of(mockResponse);
    }
    mockResponse.success = false;
    mockResponse.user = user;
    return of(mockResponse);
  }

  logout() {
    localStorage.clear();
    this.router.navigate(['login']);
  }

  get loggedUser(): IUser {
    return localStorage.getItem('user')
      ? JSON.parse(atob(localStorage.getItem('user')))
      : null;
  }

  get loggedUserId(): string {
    return localStorage.getItem('user')
      ? (JSON.parse(atob(localStorage.getItem('user'))) as IUser).id
      : null;
  }

  get userToken(): string {
    return localStorage.getItem('token')
      ? JSON.parse(atob(localStorage.getItem('token')))
      : null;
  }

  get isLoggedIn(): boolean {
    return localStorage.getItem('token') ? true : false;
  }
}

Entendendo o mock do login com Angular

Repare no método login(). Como não existe uma API real neste exemplo, o serviço simula o retorno com o método mockUserLogin().

O método recebe o usuário, com e-mail e senha vindos do formulário. Então, se o e-mail for hello@balta.io e a senha for 123, o mock considera o usuário autenticado.

Nesse caso, o serviço salva o token e os dados do usuário no storage e redireciona para a Home. Caso contrário, ele apenas devolve success como falso.

Esse mock permite testar todo o login com Angular antes mesmo de existir um back end pronto.

A versão comentada mostra como ficaria a chamada real com HttpClient. Na prática, o tratamento do retorno depende de como a sua API devolve os dados, embora esse formato com token e usuário seja bastante comum.

Base64 não é criptografia

Para gravar os dados, o exemplo usa btoa e atob, que apenas convertem o conteúdo para Base64. O objetivo é só evitar que os valores fiquem legíveis à primeira vista no navegador.

Contudo, qualquer pessoa decodifica Base64 em segundos, porque se trata de uma codificação e não de uma cifra. Portanto, não trate essa conversão como camada de segurança.

Além disso, qualquer script que rode na página consegue ler o localStorage. Por esse motivo, um ataque de XSS pode roubar um token guardado ali, como explica este comparativo entre localStorage e cookies.

Em cenários mais sensíveis, avalie cookies httpOnly e secure, que o JavaScript não consegue ler. Ainda assim, essa opção exige cuidados contra CSRF e mudanças na API.

Construindo a tela de login com Angular

Com o serviço pronto, chegou a hora do formulário que o usuário vai preencher no login com Angular. Para a validação, o exemplo usa Reactive Forms, que deixam as regras no TypeScript e o template mais limpo.

Template em login.component.html:

<mat-card-content class="container">
  <mat-card class="login-card">
    <mat-card-title>Login</mat-card-title>
    <form [formGroup]="loginForm" novalidate>
      <mat-card-content class="login-content">
        <mat-form-field appearance="outline" class="form-field-full">
          <mat-label>Email</mat-label>
          <input type="email" matInput formControlName="email" placeholder="e.g. john@example.com">
          <mat-error *ngIf="loginForm.get('email').hasError('email') && loginForm.get('email').touched">
            Please enter a valid email
          </mat-error>
          <mat-error *ngIf="loginForm.get('email').hasError('required') && loginForm.get('email').touched">
            Email is <strong>required</strong>
          </mat-error>
        </mat-form-field>
        <mat-form-field appearance="outline" class="form-field-full">
          <mat-label>Password</mat-label>
          <input type="password" matInput formControlName="password">
          <mat-error *ngIf="loginForm.get('password').invalid && loginForm.get('password').touched">
            Password is <strong>required</strong>
          </mat-error>
        </mat-form-field>
      </mat-card-content>
    </form>
    <mat-card-actions align="end">
      <button mat-button color="primary" (click)="login()">Sign in</button>
    </mat-card-actions>
  </mat-card>
</mat-card-content>

Estilos em login.component.scss:

.container {
  display: flex;
  height: 100%;
  justify-content: center;
  align-items: center;
}
.login-content {
  display: flex;
  flex-direction: column;
}
.login-card {
  width: 300px;
  box-shadow: 10px 5px 5px rgba(0, 0, 0, 0.4);
}

Lógica em login.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { IUser } from '../../interfaces/IUser';
import { UserService } from '../../services/user.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
  loginForm: FormGroup;

  constructor(private formBuilder: FormBuilder,
              private userService: UserService,
              private snackBar: MatSnackBar) { }

  ngOnInit(): void {
    this.createForm();
  }

  createForm() {
    this.loginForm = this.formBuilder.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required]]
    });
  }

  login() {
    // Não envia nada se o formulário estiver inválido
    if (this.loginForm.invalid) return;

    const user = this.loginForm.getRawValue() as IUser;
    this.userService.login(user).subscribe((response) => {
      if (!response.success) {
        this.snackBar.open('Authentication failed', 'Invalid email or password.', {
          duration: 3000
        });
      }
    });
  }
}

O método createForm() define dois campos obrigatórios, e o e-mail ainda passa pelo validador de formato. Em seguida, o método login() envia os dados ao serviço e mostra um aviso com MatSnackBar quando a autenticação falha.

Veja como o fluxo fica até aqui, já com a validação e o redirecionamento funcionando:

Animação da tela de login com Angular validando os campos e redirecionando para a Home após a autenticação
Formulário validando os campos e autenticando o usuário de teste.

Protegendo as rotas do login com Angular

Até aqui, nada impede alguém de digitar http://localhost:4200/ e acessar a Home sem autenticação. Ou seja, o login com Angular ainda não tem utilidade real, porque nada bloqueia o acesso direto pela URL.

Para resolver isso, você vai criar dois guards do tipo CanActivate. O primeiro manda para a Home quem já entrou, enquanto o segundo manda para o login quem ainda não entrou.

ng g guard services/guards/unauthenticated-user
ng g guard services/guards/authenticated-user

authenticated-user.guard.ts

import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { UserService } from '../user.service';

@Injectable({
  providedIn: 'root'
})
export class AuthenticatedUserGuard implements CanActivate {
  constructor(private userService: UserService,
              private router: Router) { }

  canActivate() {
    // Libera a rota apenas para quem tem token no storage
    if (this.userService.isLoggedIn) {
      return true;
    }
    this.router.navigate(['login']);
    return false;
  }
}

unauthenticated-user.guard.ts

import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { UserService } from '../user.service';

@Injectable({
  providedIn: 'root'
})
export class UnauthenticatedUserGuard implements CanActivate {
  constructor(private userService: UserService,
              private router: Router) { }

  canActivate() {
    // Quem já entrou não precisa ver a tela de login de novo
    if (this.userService.isLoggedIn) {
      this.router.navigate(['']);
      return false;
    }
    return true;
  }
}

Agora basta aplicar os guards no arquivo app-routing.module.ts:

const routes: Routes = [
  { path: 'login', component: LoginComponent, canActivate: [UnauthenticatedUserGuard] },
  {
    path: '', component: MainComponent, canActivate: [AuthenticatedUserGuard],
    children: [
      { path: '', component: HomeComponent }
    ],
  },
];

Como o guard está na rota pai, todas as rotas filhas herdam a proteção do login com Angular. Assim, qualquer página nova que você criar dentro do layout já nasce protegida.

Guards de classe em versões recentes

Este tutorial mantém os guards baseados em classe, padrão na versão do Angular usada no exemplo. Porém, a partir do Angular 15, a documentação marcou essa implementação como obsoleta em favor de guards funcionais.

Segundo o artigo da HeroDevs sobre guards funcionais, a nova forma usa funções e a função inject() para obter dependências. Mesmo assim, a lógica de decisão continua igual à que você viu aqui.

Implementando o logout no login com Angular

Para testar o fluxo inteiro, falta ligar o botão de logout ao serviço. Primeiro, adicione o evento de clique no botão do layout:

main.component.html

<button mat-icon-button (click)="logout()">
  <mat-icon>logout</mat-icon>
</button>

Depois, no arquivo main.component.ts, injete o UserService e chame o método logout(). Esse método limpa o storage e devolve o usuário para a tela de login.

import { Component } from '@angular/core';
import { UserService } from '../../../services/user.service';

@Component({
  selector: 'app-main',
  templateUrl: './main.component.html',
  styleUrls: ['./main.component.scss']
})
export class MainComponent {
  constructor(private userService: UserService) { }

  logout() {
    this.userService.logout();
  }
}

Para validar, tente abrir a rota raiz sem autenticação e, depois, tente abrir a rota de login já autenticado. Em ambos os casos, os guards devem redirecionar você para o lugar certo.

Com isso, o login com Angular já funciona de ponta a ponta no front end.

Enviando o token do login com Angular

Depois do login com Angular, cada chamada protegida para a API precisa levar o token do usuário. Esse token prova à API que o usuário já se autenticou e pode executar ações conforme suas permissões.

Se você quiser entender o formato desse token, o artigo o que é JWT explica a estrutura em detalhes. Do lado do servidor, o tutorial de autenticação e autorização com JWT e Bearer no ASP.NET 7 mostra como a API valida esse cabeçalho.

Para gerar o interceptor, use o comando ng g interceptor services/interceptors/token. O arquivo fica assim:

token.interceptor.ts

import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { UserService } from '../user.service';

@Injectable()
export class TokenInterceptor implements HttpInterceptor {
  constructor(private userService: UserService) { }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = this.userService.userToken;
    const requestUrl: Array<any> = request.url.split('/');
    const apiUrl: Array<any> = environment.apiUrl.split('/');

    // Só adiciona o token quando a chamada vai para a nossa API
    if (token && requestUrl[2] === apiUrl[2]) {
      request = request.clone({
        setHeaders: {
          Authorization: `Bearer ${token}`,
          token: `${token}`
        }
      });
      return next.handle(request).pipe(catchError(error => {
        // 401: o token não vale mais, então encerramos a sessão
        if (error instanceof HttpErrorResponse && error.status === 401) {
          this.userService.logout();
        }
        return throwError(error);
      }));
    }

    return next.handle(request);
  }
}

Como o interceptor funciona

De forma resumida, o código acima executa os passos a seguir:

  1. Primeiro, lê o token do usuário que está no storage.
  2. Depois, pega a URL da requisição em andamento.
  3. Em seguida, obtém a apiUrl configurada no environment.
  4. Compara o host das duas URLs para saber se a chamada vai para a nossa API.
  5. Caso vá, adiciona o token no cabeçalho da requisição.
  6. Caso não vá, segue com a requisição original.
  7. Ao terminar, verifica se a resposta trouxe algum erro.
  8. Se o erro for 401 Unauthorized, encerra a sessão, porque o usuário não está mais autenticado na API.

Note que o interceptor usa request.clone() em vez de alterar a requisição. Isso acontece porque, segundo a documentação de interceptors do Angular, requisições e respostas são imutáveis e as alterações sempre passam por um clone.

Além disso, a comparação de host evita um problema sério no login com Angular. Sem ela, o token iria junto em chamadas para qualquer domínio externo que a aplicação acessasse.

Registrando o interceptor

Para fechar, registre o interceptor nos providers do app.module.ts, usando o token HTTP_INTERCEPTORS com multi: true:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { LoginComponent } from './pages/login/login.component';
import { HomeComponent } from './pages/home/home.component';
import { MainComponent } from './pages/shared/main/main.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { TokenInterceptor } from './services/interceptors/token.interceptor';

@NgModule({
  declarations: [
    AppComponent,
    LoginComponent,
    HomeComponent,
    MainComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    HttpClientModule,
    ReactiveFormsModule,
    FormsModule,
    MatCardModule,
    MatInputModule,
    MatButtonModule,
    MatSidenavModule,
    MatToolbarModule,
    MatIconModule,
    MatSnackBarModule
  ],
  providers: [
    // multi: true permite registrar vários interceptors
    { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

A opção multi: true permite que vários interceptors convivam no mesmo token de injeção. Vale lembrar que, nesse modelo, eles rodam na ordem em que você registra os providers.

Conclusão

Neste tutorial, você construiu um login com Angular completo, com formulário reativo, logout, guards e envio automático do token. Cada peça tem uma responsabilidade clara, o que facilita manter e evoluir o código.

O storage guarda a sessão, os guards controlam o acesso às rotas e o interceptor cuida da comunicação com a API. Por fim, troque o mock pela chamada real assim que a sua API de autenticação estiver pronta.

A partir dessa base, você pode evoluir o login com Angular com refresh token, perfis de acesso e telas de cadastro.

O código completo do exemplo original está disponível no repositório AngularLoginExemplo no GitHub.

FAQ

Onde guardar o token em uma aplicação Angular?

O exemplo usa localStorage pela simplicidade. Em cenários mais sensíveis, avalie cookies httpOnly e secure, que o JavaScript da página não consegue ler.

Converter o token para Base64 deixa o login mais seguro?

Não. Base64 é apenas uma codificação reversível e serve só para deixar o valor menos legível no navegador.

Por que usar dois guards em vez de um?

Um guard protege as páginas internas de quem não entrou. O outro impede que um usuário já autenticado volte para a tela de login.

O interceptor envia o token para qualquer URL?

Não. Ele compara o host da requisição com a apiUrl do environment e só adiciona o cabeçalho nas chamadas para a sua API.

O que acontece quando a API responde 401?

O interceptor chama o logout(), limpa o storage e leva o usuário de volta para a tela de login.

Glossário

Termo Significado
Storage Área de armazenamento do navegador, como o localStorage, que guarda dados entre navegações.
Route guard Classe ou função que decide se o roteador pode ativar uma rota.
CanActivate Interface de guard que o roteador consulta antes de ativar uma rota.
Interceptor Componente que intercepta requisições e respostas HTTP para alterá-las ou tratá-las.
Token Credencial emitida pela API após a autenticação e enviada nas requisições seguintes.
Bearer Esquema do cabeçalho Authorization usado para enviar tokens de acesso.
Base64 Codificação reversível que transforma dados binários em texto.
Reactive Forms Abordagem do Angular para formulários com estado e validação definidos no TypeScript.
Mock Implementação simulada que substitui uma dependência real durante o desenvolvimento.
XSS Ataque em que um script malicioso roda dentro da página da vítima.

Fontes