Como proteger suas rotas - Angular Guard

Exemplo de como proteger suas rotas com a implementação do CanActivate (guard) do Angular.

Diagrama ilustrando um redirecionamento de rota por parte do Guard do Angular
Como proteger suas rotas - Angular Guard

Quando uma aplicação é desenvolvida, é comum segregar seu uso em várias páginas, ou componentes no caso do Angular, criando rotas de navegação. No mundo real nem todas as rotas devem ser acessíveis dependendo da regra de negócio. Isto faz sentido, por exemplo, quando trabalhamos com perfis ou quando queremos apenas proteger certas rotas de usuários anônimos.

Neste texto será demonstrado a criação de um projeto com duas rotas:

  • /home - será protegida por uma classe que implementa a interface CanActivate. Esta classe irá validar se o usuário foi autenticado, caso contrário irá redirecioná-lo para o login
  • /login - formulário simples para autenticação do usuário

Criando o Projeto

Para criar o projeto foram utilizadas as seguintes versões:

$ ng version

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/
    

Angular CLI: 15.0.4
Node: 18.12.1
Package Manager: npm 8.19.2
OS: darwin arm64

Angular: 
... 

Package                      Version
------------------------------------------------------
@angular-devkit/architect    0.1500.4 (cli-only)
@angular-devkit/core         15.0.4 (cli-only)
@angular-devkit/schematics   15.0.4 (cli-only)
@schematics/angular          15.0.4 (cli-only)

Criando o projeto:

$ ng new consolelog-guards --minimal true --routing true --skip-git true

Descrição das opções utilizadas acima:

  • minimal: Cria o projeto sem um framework para testes. Ideal para estudo
  • routing: Gera um módulo de roteamento
  • skip-git: Não inicia o repositório git. Como é um cenário de estudo, não foi necessário o uso do git.

Se quiser ler mais sobre as opções do comando new do Angular CLI acesse o link a seguir: https://angular.io/cli/new


Abrindo e executando o projeto:

$ cd consolelog-guards
$ npm start
Navegador no endereço localhost:4200 mostrando a tela inicial do projeto padrão do Angular
Projeto recém-criado em execução

Estruturando o projeto

Para começar, vamos criar todos os arquivos que serão utilizados ao longo deste texto. Cada arquivo será explicado ao decorrer da leitura.

# O "--inline-template true" não cria o arquivo
#   home.component.html, ele deixa o código HTML
#   dentro do arquivo home.component.ts
$ ng generate component home --inline-template true
CREATE src/app/home/home.component.ts (186 bytes)
UPDATE src/app/app.module.ts (467 bytes)

# Será a página de login
$ ng generate component login --inline-template false
CREATE src/app/login/login.component.html (20 bytes)
CREATE src/app/login/login.component.ts (175 bytes)
UPDATE src/app/app.module.ts (546 bytes)

# O "model" indica o tipo de interface
#  que está sendo criada. Então o CLI
#  criará o arquivo sessao.model.ts
$ ng generate interface sessao model
CREATE src/app/sessao.model.ts (28 bytes)

# Classe que irá controlar o estado de sessão
$ ng generate service sessao
CREATE src/app/sessao.service.ts (135 bytes)

$ ng generate guard auth
? Which interfaces would you like to implement? CanActivate
CREATE src/app/auth.guard.ts (457 bytes)
script para criar os arquivos

Ao executar os comandos acima a estrutura de arquivos ficará da seguinte forma:

consolelog-guards/
├─ node_modules/
├─ src/
│  ├─ app.component.html
│  ├─ app.component.ts
│  ├─ app.module.ts
│  ├─ app-routing.module.ts
│  │
│  ├─ sessao.model.ts
│  ├─ sessao.service.ts
│  │
│  ├─ auth.guard.ts
│  │
│  ├─ login/
│  │  ├─ login.component.html
│  │  ├─ login.component.ts
│  │
│  ├─ home/
│  │  ├─ home.component.ts
├─ package.json

Observações:

  • Alguns arquivos foram omitidos na lista acima por não serem relevantes para o contexto.
  • Não houve uma estruturação de diretórios para organizar os arquivos já que não é relevante para este estudo.

HomeComponent

Este componente foi construído da forma mais simples possível, veja o código a seguir:

import { Component } from "@angular/core";

@Component({
  template: ` <p>home works!</p> `,
})
export class HomeComponent {}
home.component.ts

Para criar uma nova rota e vinculá-la a este componente, basta editar o arquivo app-routing.module.ts adicionando a configuração na variável routes:

import { NgModule } from "@angular/core";
import {
  RouterModule,
  Routes,
} from "@angular/router";
import { HomeComponent } from "./home/home.component";

const routes: Routes = [
  {
    path: "home",
    component: HomeComponent,
  },
  {
    path: "",
    redirectTo: "/home",
    pathMatch: "full",
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}
app-routing.module.ts

A segunda rota, na linha 13, indica que quando o path (localhost:4200/<path>) estiver vazio, ocorrerá um redirect para a rota /home.

Para testar basta executar o projeto e ver que o conteúdo do HomeComponent está sendo exibido na tela:

Navegador no endereço localhost:4200/home mostrando o conteúdo do HomeComponent e AppComponent
Projeto em execução mostrando o conteúdo do HomeComponent e AppComponent

Agora vamos reduzir o HTML do AppComponent para:

import { Component } from "@angular/core";

@Component({
  selector: "app-root",
  template: `
    <div>O menu será construído aqui!</div>
    <router-outlet></router-outlet>
  `,
})
export class AppComponent { }
app.component.ts

Executando o projeto:

Navegador no endereço localhost:4200/home mostrando o conteúdo do AppComponent e HomeComponent
Projeto em execução mostrando o conteúdo do AppComponent e HomeComponent

SessaoService

O arquivo sessao.model.ts conterá apenas uma interface para tipagem do retorno da API de login (que será mockada mais a frente).

export interface Sessao {
  nome: string;
  accessToken: string;
}
sessao.model.ts

Já a classe SessaoService será responsável por receber os dados da autenticação do usuário, salvá-los (no caso será o sessionStorage) e disponibilizá-los através de um Observable (método getSessao()):

Deixei alguns comentários para facilitar o entedimento:

import { Injectable } from "@angular/core";
import { BehaviorSubject } from "rxjs";
import { Sessao } from "./sessao.model";

const CHAVE_ACCESS_TOKEN = "auth";

@Injectable({
  providedIn: "root",
})
export class SessaoService {
  private sessao =
    new BehaviorSubject<Sessao | null>(null);

  constructor() {
    // Quando o usuário pressionar F5 para
    // atualizar a página, esta classe será
    // recriada e o contrutor ativado.
    // Quando isto ocorrer, vamos tentar
    // resgatar a sessão do usuário do
    // sessionStorage:
    this.restaurarSessao();
  }

  restaurarSessao() {
    const jsonSessao = sessionStorage.getItem(
      CHAVE_ACCESS_TOKEN
    );

    if (!jsonSessao) {
      return;
    }

    const dadosSessao: Sessao =
      JSON.parse(jsonSessao);
    this.sessao.next(dadosSessao);
  }

  salvarSessao(dadosSessao: Sessao) {
    sessionStorage.setItem(
      CHAVE_ACCESS_TOKEN,
      JSON.stringify(dadosSessao)
    );

    // Dispara um novo valor para
    // quem está "ouvindo" o Observable
    // retornado pelo método getSessao
    this.sessao.next(dadosSessao);
  }

  limparSessao() {
    sessionStorage.clear();
    this.sessao.next(null);
  }

  /**
   * Retorna um Obsersable com os
   * dados da sessão do usuário
   */
  getSessao() {
    return this.sessao.asObservable();
  }

  estaLogado() {
    return this.sessao.value !== null;
  }
}
sessao.service.ts

Aqui vale um comentário rápido, o BehaviorSubject nos ajuda a compartilhar dados entre componentes/services/etc. Então podemos compartilhar os dados de sessão através do Observable retornado pelo método getSessao(). Basta efetuar um .subscribe() e ficar "ouvindo" as alterações. Usaremos este recurso ao longo do código.

LoginComponent

Sem grandes complicações, neste componente foi adicionado um formulário bem simples e uma simulação à uma chamada de API para validar o usuário e senha:

import { Component } from "@angular/core";
import {
  FormControl,
  FormGroup,
  Validators,
} from "@angular/forms";
import { Router } from "@angular/router";
import { of, throwError } from "rxjs";
import { SessaoService } from "../sessao.service";

@Component({
  selector: "app-login",
  templateUrl: "./login.component.html",
  styles: [],
})
export class LoginComponent {
  formGroup: FormGroup = new FormGroup({
    usuario: new FormControl<string>("", {
      nonNullable: true,
      validators: [
        Validators.required,
        Validators.min(2),
      ],
    }),
    senha: new FormControl<string>("", {
      nonNullable: true,
      validators: [
        Validators.required,
        Validators.min(6),
      ],
    }),
  });

  constructor(
    private sessaoService: SessaoService,
    private router: Router
  ) {}

  login() {
    if (this.formGroup.invalid) {
      return;
    }

    const { usuario, senha } =
      this.formGroup.value;

    // IMPORTANTE: para facilitar o estudo,
    // utilizamos a lógica abaixo, porém, no
    // mundo real haveria uma chamada à uma
    // service para acionar uma API e validar
    // o usuário e senha.
    this.simularChamadaAPI(
      usuario,
      senha
    )?.subscribe({
      next: (resposta) => {
        this.sessaoService.salvarSessao(resposta);
        this.router.navigate(["/home"]);
      },
      error: (erro) => {
        alert(erro);
        this.formGroup.reset();
      },
    });
  }

  simularChamadaAPI(
    usuario: string,
    senha: string
  ) {
    return usuario === "consolelog" &&
      senha === "123456"
      ? // Usuário válido
        of({
          accessToken: "aaa",
          nome: "ConsoleLog",
        })
      : // Usuário inválido
        throwError(() => {
          const error: any = new Error(
            `Usuário ou senha inválido`
          );
          error.timestamp = Date.now();
          return error;
        });
  }
}
login.component.ts
<form
  (ngSubmit)="login()"
  [formGroup]="formGroup"
>
  <div>
    <input
      type="text"
      formControlName="usuario"
    />
  </div>
  <div>
    <input
      type="password"
      formControlName="senha"
    />
  </div>
  <div>
    <input type="submit" value="Login" />
  </div>
</form>
login.component.html

Ao salvar o projeto, possívelmente você tomará o seguinte erro:

src/app/login/login.component.html:3:3 - error NG8002: Can't bind to 'formGroup' since it isn't a known property of 'form'.

Este erro ocorre porque falta importar o ReactiveFormsModule no AppModule:

import { NgModule } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { BrowserModule } from "@angular/platform-browser";

import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { HomeComponent } from "./home/home.component";
import { LoginComponent } from "./login/login.component";

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    LoginComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ReactiveFormsModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
app.module.ts

Com o componente codificado podemos criar uma rota para vinculá-lo:

import { NgModule } from "@angular/core";
import {
  RouterModule,
  Routes,
} from "@angular/router";
import { HomeComponent } from "./home/home.component";
import { LoginComponent } from "./login/login.component";

const routes: Routes = [
  {
    path: "home",
    component: HomeComponent,
  },
  {
    path: "login",
    component: LoginComponent,
  },
  {
    path: "",
    redirectTo: "/home",
    pathMatch: "full",
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}
app-routing.module.ts
Navegador renderizando o conteúdo do LoginComponent para a rota /login
Conteúdo do LoginComponent sendo renderizado para a rota /login

Ajustando o menu

Agora vamos construir um menu condicional no AppComponent, que depende do estado de sessão do usuário:

import { Component } from "@angular/core";
import { Observable } from "rxjs";
import { Sessao } from "./sessao.model";
import { SessaoService } from "./sessao.service";

@Component({
  selector: "app-root",
  template: `
    <div
      *ngIf="
        sessao$ | async as sessao;
        else menuNaoLogado
      "
    >
      Olá {{ sessao.nome }}!
      <a routerLink="/home">Home</a> |
      <a routerLink="/login" (click)="logout()"
        >Logout</a
      >
    </div>
    <router-outlet></router-outlet>
    <ng-template #menuNaoLogado>
      usuário não logado
    </ng-template>
  `,
})
export class AppComponent {
  title = "consolelog-guards";
  sessao$: Observable<Sessao | null>;

  constructor(
    private sessaoService: SessaoService
  ) {
    this.sessao$ = this.sessaoService.getSessao();
  }

  logout() {
    this.sessaoService.limparSessao();
  }
}
app.component.ts

O async (AsyncPipe) retorna o último valor de um Observable ou Promise. Neste exemplo foi utilizado para "ouvir" os valores do Observable retornado pelo this.sessaoService.getSessao().


Até aqui o mecanismo construído funcionou conforme o esperado, o problema é que a rota /home está acessível para qualquer usuário, veja no GIF abaixo que a segunda aba do navegador está no path /home e o usuário não está logado. Pouco tempo depois a primeira aba do navegador é ativada e o usuário faz sua identificação, então qualquer um pode acessar o /home.

Navegando pelas rotas /login e /home com um usuário logado e um anônimo
Testando o acesso as rotas com um usuário logado e um usuário anônimo

Criando um Guard

Para proteger este path, /home, podemos recorrer a uma implementação da interface CanActivate para guardar (proteger) nossa rota. Basicamente vamos "autorizar" o Angular a prosseguir com a renderização da rota /home caso o usuário esteja autenticado, caso contrário iremos redirecioná-lo para a /login. O código é bem simples, veja a seguir:

Interface that a class can implement to be a guard deciding if a route can be activated. If all guards return true, navigation continues. If any guard returns false, navigation is cancelled. If any guard returns a UrlTree, the current navigation is cancelled and a new navigation begins to the UrlTree returned from the guard.

https://angular.io/api/router/CanActivate#description
import { Injectable } from "@angular/core";
import {
  ActivatedRouteSnapshot,
  CanActivate,
  Router,
  RouterStateSnapshot,
  UrlTree,
} from "@angular/router";
import { Observable } from "rxjs";
import { SessaoService } from "./sessao.service";

@Injectable({
  providedIn: "root",
})
export class AuthGuard implements CanActivate {
  constructor(
    private sessionService: SessaoService,
    private router: Router
  ) {}

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ):
    | Observable<boolean | UrlTree>
    | Promise<boolean | UrlTree>
    | boolean
    | UrlTree {
    // Se o usuário estiver sem sessão,
    // o enviamos para a tela de login
    if (this.sessionService.estaLogado()) {
      return true;
    }

    return this.router.parseUrl("/login");
  }
}
auth.guard.ts

Perceba que na assinatura do método canActivate o retorno pode ser:

  • Observable<boolean | UrlTree>
  • Promise<boolean | UrlTree>
  • boolean
  • UrlTree

Então podemos retornar:

  • true (boolean) caso o usuário esteja logado, ou seja, pode acessar a rota
  • UrlTree para efetuar um redirecionamento para a rota /login caso não esteja autenticado.

Protegendo a rota com o guard

Com a implementação do CanActivate, basta declarar o AuthGuard no campo canActivate na configuração da rota (linha 14):

import { NgModule } from "@angular/core";
import {
  RouterModule,
  Routes,
} from "@angular/router";
import { AuthGuard } from "./auth.guard";
import { HomeComponent } from "./home/home.component";
import { LoginComponent } from "./login/login.component";

const routes: Routes = [
  {
    path: "home",
    component: HomeComponent,
    canActivate: [AuthGuard],
  },
  {
    path: "login",
    component: LoginComponent,
  },
  {
    path: "",
    redirectTo: "/home",
    pathMatch: "full",
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}
app-routing.module.ts

Testando:

Usuário anônimo tentando acesso a rota /home no navegador e sendo redirecionado para /login
Guard redirecionando um usuário anônimo para a rota /login

Considerações

O uso dos Guards é bem simples e muito útil. Pessoalmente utilizei diversas vezes em grandes projetos e funcionou muito bem. Por aqui abordamos somente o CanActivate, mas há outros recursos ligados a proteção de rotas: https://angular.io/guide/router#preventing-unauthorized-access

Vale destacar que recentemente foi lançado o Angular 15. Uma das novidades é a possibilidade de reduzir a quantidade de código escrito para utilizar um Guard. Dê uma olhada neste link na parte Functional router guards.

Para finalizar deixo um link do StackBlitz com o código completo:

https://stackblitz.com/edit/angular-guard-proteger-rota-consolelog?file=src/app/app.component.ts