12 - Authentication

Intro

Authentication: azione che riconosce l'utente come loggato
Authorization: azione che verifica se l'utente è autorizzato ad accedere a una determinata risorsa

Per questo corso noi ci limitiamo a vedere la parte di autenticazione.

Sessioni

session_auth.png

  1. l'utente esegue il login
  2. il server crea e salva un id di sessione
  3. l'id di sessione viene salvato nei cookie del browser
  4. il client invia l'id nei cookie di ogni richiesta
  5. il server ad ogni chiamata verifica che la sessione sia ancora valida

Svantaggi:

  • se si scala su più server è necessario sincronizzare le sessioni
  • ogni sessione deve essere salvata sul server, con possibili problemi nel caso di grosse moli di utenti
  • i cookie sono legati a un dominio e sono uno strumento specifico dei browser. Sono più difficili da gestire ad esempio su app mobili o nelle chiamate tra due server

JWT

jwt_auth.webp

  1. l'utente esegue il login
  2. il server crea un token e lo torna al client
  3. il client salva il token (generalmente nel localStorage ma non ha importanza)
  4. il client invia il token negli header di ogni richiesta
  5. il server ad ogni chiamata verifica la validità del token (tutte le informazioni necessarie sono il token stesso e la chiave con cui è stato cifrato)

Vantaggi:

  • il token è salvato solo sul client, nel caso di più server è sufficiente che condividano la stessa chiave di cifratura
  • viene inviato nell'header, quindi è compatibile con diversi dipi di client

OAuth2

oauth_auth.png

  1. l'utente dichiara a un provider terzo di voler autorizzare una certa applicazione
  2. il provider autorizza la richiesta e torna un token di autorizzazione (jwt in genere)
  3. il client invia al server le richieste con il token negli header
  4. il server chiede al provider di verificare il token
  5. il provider torna al server i dati che l'utente ha acconsentito a condividere con l'app

Vantaggi:

  • integrazione a terze parti senza necessità di condividere le credenziali
  • sicurezza gestita dal provider e non dall'app
  • accesso solo ai dati approvati dall'utente
  • possibilità per l'utente di rimuovere l'autorizzazione all'app
  • ...

API

Andremo ad utilizzare una libreria che si chiama passport per implementare un'autenticazione tramite JWT con credenziali salvate a db.
Questa libreria fornisce uno strato di astrazione che permette di adottare diverse strategie di autenticazione. Una volta configurate le strategie passport mette a disposizione dei middleware per verificare che l'utente sia autenticato.

router.get('/users/me', passport.authenticate('jwt'), userController.getMyUser);

La configurazione delle strategie permette di delegare all'applicazione la logica con cui l'utente viene verificato. Nel caso in cui l'autenticazione vada a buon fine il middleware aggiunge la proprietà user all'oggetto req e procede con la catena di middleware registrati. In questo modo nei controller avremo a disposizione direttamente l'utente che ha effettuato la richiesta, senza doverci preoccupare della logica utilizzata per recuperare l'informazione.
Nel caso in cui l'autenticazione fallisca viene tornato 401 Unauthorized.

Installazione

npm i passport passport-local passport-jwt
npm i --save-dev @types/passport @types/passport-local @types/passport-jwt

Configurazione

Creiamo la cartella utils/auth che conterrà le nostre configurazioni. In genere si crea una sottocartella di auth per ogni strategia utilizzata, nel nostro caso andremo ad utilizzare jwt e local.
Nello specifico andremo ad utilizzare la local strategy per eseguire il login (anche se non strettamente necessario è comodo usare passport anche per questa singola operazione), il login tornerà il token jwt necessario ad autenticare le richieste alle api, questo verrà fatto utilizzando la jwt strategy.

Local Strategy

La local strategy è quella che usa username e password, è buona norma salvare queste informazioni in una collezione diversa da quella che contiene i dati dell'utente. Questo permette di:

  • avere più metodi di autenticazione per ogni utente (OAuth, telefono, passkeys, ecc)
  • avere la collezione users già pulita dei dati di autenticazione, in modo da non doverli rimuovere ogni volta dalle risposte

Andiamo a creare i file necessari a gestire gli utenti:
api/user/user.entity.ts

export interface User {
    id: string;
    firstName: string;
    lastName: string;
    fullName: string;
    picture: string;
}

api/user/user.model.ts

import  mongoose from 'mongoose';
import { User as iUser} from './user.entity';

export const userSchema = new mongoose.Schema<iUser>({
  firstName: String,
  lastName: String,
  picture: String
});

userSchema.virtual('fullName').get(function() {
    return `${this.firstName} ${this.lastName}`;
});

userSchema.set('toJSON', {
  virtuals: true,
  transform: (_, ret) => {
    delete ret._id;
    delete ret.__v;
    return ret;
  }
});

userSchema.set('toObject', {
  virtuals: true,
  transform: (_, ret) => {
    delete ret._id;
    delete ret.__v;
    return ret;
  }
});

export const User = mongoose.model<iUser>('User', userSchema);

utils/auth/local/user-identity.entity.ts

import { User } from "../../../api/user/user.entity";

export interface UserIdentity {
    id: string;
    provider: string;
    credentials: {
        username: string;
        hashedPassword: string;
    };
    user: User;
}

utils/auth/local/user-identity.model.ts

import  mongoose, { Schema } from 'mongoose';
import { UserIdentity as iUserIdentity} from './user-identity.entity';

export const userIdentitySchema = new mongoose.Schema<iUserIdentity>({
  user: {type: Schema.Types.ObjectId, ref: 'User'},
  provider: {type: String, default: 'local'},
  credentials: {type: {
    username: String,
    hashedPassword: String
  }}
});

userIdentitySchema.pre('findOne', function(next) {
  this.populate('user');
  next();
});


export const UserIdentity = mongoose.model<iUserIdentity>('UserIdentity', userIdentitySchema);

Installiamo una libreria che ci serve per cifrare le password salvate e confrontarle al momento del login:

npm i bcrypt
npm i --save-dev @types/bcrypt

Creazione di un utente

api/auth/auth.dto.ts

import { IsEmail, IsString, IsUrl, Matches, MinLength } from "class-validator";

export class AddUserDTO {
  @IsString()
  firstName: string;

  @IsString()
  lastName: string;

  @IsUrl()
  picture: string;

  @IsEmail()
  username: string;

  @MinLength(8)
  @Matches(
    new RegExp('^(?=.*\d)(?=.*[!@#$%^&*])(?=.*[a-z])(?=.*[A-Z]).{8,}$'),
    {
      message: 'password must contain at least 1 uppercase letter, 1 lowercase letter, 1 number and a special character'
    }
  )
  password: string;
}

api/user/user.service.ts

import { User as UserModel } from "./user.model";
import { UserIdentity as UserIdentityModel } from "../../utils/auth/local/user-identity.model";
import { User } from "./user.entity";
import { UserExistsError } from "../../errors/user-exists";
import * as bcrypt from 'bcrypt';

export class UserService {

  async add(user: User, credentials: {username: string, password: string}): Promise<User> {
    const existingIdentity = await UserIdentityModel.findOne({'credentials.username': credentials.username});
    if (existingIdentity) {
      throw new UserExistsError();
    }
    const hashedPassword = await bcrypt.hash(credentials.password, 10);

    const newUser = await UserModel.create(user);

    await UserIdentityModel.create({
      provider: 'local',
      user: newUser._id,
      credentials: {
        username: credentials.username,
        hashedPassword
      }
    })

    return newUser;
  }
  
}

export default new UserService();

Nulla di diverso da quello che abbiamo fatto in precedenza nei servizi, l'unica cosa interessante è l'utilizzo di bcrypt, che va a cifrare la password prima di salvarla, in modo da nasconderla al personale che ha accesso al db e in caso di data breach.

errors/user-exists.ts

export class UserExistsError extends Error {
  constructor() {
    super();
    this.name = 'UserExists';
    this.message = 'username already in use';
  }
}

In questo caso non prevediamo di lanciare l'errore da più punti dell'app, quindi non serve creare un handler globale. Ci basta lanciare l'errore dal servizio e gestirlo dal controller.

api/auth/auth.controller.ts

import { NextFunction, Response } from "express";
import { TypedRequest } from "../../utils/typed-request.interface";
import { AddUserDTO } from "./auth.dto";
import { omit, pick } from 'lodash';
import { UserExistsError } from "../../errors/user-exists";
import userService from '../user/user.service';

export const add = async (
  req: TypedRequest<AddUserDTO>,
  res: Response,
  next: NextFunction
) => {
  try {
    const userData = omit(req.body, 'username', 'password');
    const credentials = pick(req.body, 'username', 'password');
    const newUser = await userService.add(userData, credentials);
    res.send(newUser);
    
  } catch (err) {
    if (err instanceof UserExistsError) {
      res.status(400);
      res.send(err.message);
    } else {
      next(err);
    }
  }
}

api/auth/auth.router.ts

import { Router } from "express";
import { validate } from "../../utils/validation.middleware";
import { AddUserDTO } from "./auth.dto";
import { add } from "./auth.controller";


const router = Router();

router.post('/register', validate(AddUserDTO, 'body'), add);

export default router;

Login

Per effettuare il login andiamo a sfruttare la local-strategy di passport.
Andiamo per prima cosa a configurarla:

utils/auth/local/local-strategy.ts

import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { UserIdentity } from "./user-identity.model";
import * as bcrypt from 'bcrypt';

passport.use(
  new LocalStrategy({
    usernameField: 'username',
    passwordField: 'password',
    session: false
  }, async (username, password, done) => {
      try {
        const identity = await UserIdentity.findOne({'credentials.username': username});
        if (!identity) {
          return done(null, false, {message: `username ${username} not found`});
        }
        const match = await bcrypt.compare(password, identity.credentials.hashedPassword);
        // vado a convertirlo in oggetto semplice per eliminare i metodi di mongoose
        const plainUser = identity.toObject().user;
        if (match) {
          return done(null, plainUser);
        }
        done(null, false, {message: 'invalid password'});
      } catch (err) {
        done(err);
      }
  })
)

Solitamente le strategies prevedono una parte di configurazione e una funzione chiamata verify (l'arrow function nel codice) tramite la quale è possibile definire la logica specifica dell'applicazione. In questo caso la local strategy prende in automatico il campo username e il campo password dal body a seconda della configurazione che gli abbiamo dato, e chiama la funzione verify passandogli i valori trovati, una volta che la funzione esegue i controlli necessari va invocata la funzione done (terzo argomento di verify). Done vuole come primo argomento un eventuale errore (o null), come secondo l'oggetto utente che verrà settato all'interno di req (o false se non va a buon fine) e un oggetto info che può contenere informazioni riguardo l'autenticazione (in questo caso un messaggio che definisce eventuali errori da tornare al client).
Il fatto che l'autenticazione vada a buon fine o meno dipende dal valore di user, se è false l'autenticazione è fallita. Fallire l'autenticazione è diverso dal verificarsi di un errore e questo è il motivo per cui abbiamo a disposizione sia l'argomento error sia info. Error viene utilizzato se ad esempio non riusciamo a leggere da db, se invece la procedura va bene ma le credenziali sono sbagliate allora user è false e possiamo tenere traccia della causa attraverso l'argomento info.

Dobbiamo assicurarci che questo file venga chiamato perché la strategia sia registrata, andiamo a creare il file:
utils/auth/auth.handlers.ts

import './local/local-strategy';

e in app.ts

...
import './utils/auth/auth-handlers';

...

Una volta che abbiamo configurato la strategia passport mette a disposizione un middleware tramite

passport.authenticate('local', options, callbackFn)

Non andremo a utilizzarlo direttamente come middleware però, perché il comportamento standard non ci va bene. Di default abbiamo la possibilità di lanciare un redirect e di accedere al messaggio di errore tramite la sessione, questa modalità non ci va bene perché non abbiamo sessioni e stiamo lavorando con API, quindi niente redirect.

api/auth/auth.controller.ts

...
export const login = async (
  req: TypedRequest<LoginDTO>,
  res: Response,
  next: NextFunction
) => {
  passport.authenticate('local', (err, user, info) => {
    if (err) {
      return next(err);
    }
    if (!user) {
      res.status(401);
      res.json({
        error: 'LoginError',
        code: info.message
      });
      return;
    }
    //generate token
    res.status(200);
    res.json({
      user,
      token: 'test token'
    });
  })(req, res, next);
}

Utilizziamo quindi il custom callback di authenticate per andare a prendere user e info e formattare le nostre risposte al client. Da notare come passport.authenticate ritorna un callback, e dobbiamo quindi chiamarlo passandogli req, res e next come farebbe express.

JWT

Per prima cosa andiamo a generare il token al momento del login.

npm i jsonwebtoken
npm i --save-dev @types/jsonwebtoken

api/auth/auth.controller.ts

...
import * as jwt from 'jsonwebtoken';

const JWT_SECRET = 'my_jwt_secret';
...
export const login = async (
  req: TypedRequest<LoginDTO>,
  res: Response,
  next: NextFunction
) => {
  passport.authenticate('local', (err, user, info) => {
    if (err) {
      return next(err);
    }
    if (!user) {
      res.status(401);
      res.json({
        error: 'LoginError',
        message: info.message
      });
      return;
    }
    const token = jwt.sign(user, JWT_SECRET, {expiresIn: '7 days'});
    res.status(200);
    res.json({
      user,
      token
    });
  })(req, res, next);
}

utils/auth/jwt/jwt-strategy.ts

import passport from "passport";
import { ExtractJwt, Strategy as JwtStrategy } from "passport-jwt";
import { User as UserModel } from "../../../api/user/user.model";

export const JWT_SECRET = 'my_jwt_secret';

passport.use(
  new JwtStrategy({
    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    secretOrKey: JWT_SECRET
  },
  async (token, done) => {
    try {
      const user = await UserModel.findById(token.id);
      if (user) {
        return done(null, user.toObject());
      } else {
        return done(null, false, {message: 'invalid token'});
      }
    }catch (err) {
      done(err);
    }
  })
)

utils/auth/auth-handlers.ts

import './local/local-strategy';
import './jwt/jwt-strategy';
import { User as iUser } from '../../api/user/user.entity';

declare global {
  namespace Express {
    interface User extends iUser {
    }
  }
}

La dichiarazione dell'interfaccia User va a sovrascrivere quella di default di Express, praticamente fa in modo che l'oggetto req.user sia del tipo definito in user.entity invece di quello di default di express.

Andiamo a creare un middleware da usare nelle richieste che vogliamo proteggere dietro autenticazione.

utils/auth/authenticated.middleware.ts

import passport from "passport";

export const isAuthenticated = passport.authenticate('jwt', { session: false });

Adesso utilizzando questo middleware possiamo proteggere le nostre routes, andiamo ad esempio a proteggere cart-item.

api/cart-item/cart-item.router.ts

import { Router } from "express";
import { add, list, remove, updateQuantity } from "./cart-item.controller";
import { validate } from "../../utils/validation.middleware";
import { AddCartItemDTO, UpdateQuantityDTO } from "./cart-item.dto";
import { isAuthenticated } from "../../utils/auth/authenticated.middleware";

const router = Router();

router.use(isAuthenticated);
router.get('/', list);
router.post('/', validate(AddCartItemDTO, 'body'), add);
router.patch('/:id',
            validate(UpdateQuantityDTO),
            updateQuantity);
router.delete('/:id', remove);


export default router;

Solo passando un token valido nell'header Authorization sarà possibile tornare i risultati, le altre richieste vengono bloccate e tornano 401.

Utilizzo dell'utente

Andiamo a legare il carrello all'utente loggato, per farlo dobbiamo modificare i nostri dati aggiungendo un campo user agli elementi del carrello, tornando solo gli items dell'utente che sta facendo la richiesta.

api/cart-item/cart-item.entity.ts

import { Types } from 'mongoose';
import { Product } from '../product/product.entity';

export interface CartItem {
  id?: string;
  product: Types.ObjectId | string | Product;
  quantity: number;
  user?: Types.ObjectId;
}

api/cart-item/cart-item.model.ts

import mongoose, { Schema } from "mongoose";
import { CartItem as iCartItem } from "./cart-item.entity";

const cartItemSchema = new Schema<iCartItem>({
  product: { type: Schema.Types.ObjectId, ref: 'Product' },
  quantity: Number,
  user: Schema.Types.ObjectId
})

cartItemSchema.set('toJSON', {
  virtuals: true,
  transform: (_, ret) => {
    delete ret._id;
    delete ret.__v;
    delete ret.user;
    return ret;
  }
});

export const CartItem = mongoose.model<iCartItem>('CartItem', cartItemSchema);

Andiamo a modificare i metodi del servizio perché accettino anche lo userId e creino/cerchino i dati solo dell'utente desiderato:

api/cart-item/cart-item.service.ts

import { assign } from 'lodash';
import { CartItem } from "./cart-item.entity";
import { CartItem as CartItemModel } from './cart-item.model';
import { NotFoundError } from '../../errors/not-found';


export class CartItemService {
  
  async find(userId: string): Promise<CartItem[]> {
    return CartItemModel.find({user: userId}).populate('product');
  }

  async getById(id: string, userId: string): Promise<CartItem | null> {
    return this._getById(id, userId);
  }

  private async _getById(id: string, userId: string) {
    return CartItemModel.findOne({_id: id, user: userId}).populate('product');
  }

  async add(item: CartItem, userId: string): Promise<CartItem> {
    // const newItem = new CartItemModel(item);
    // await newItem.save();

    const existing = await CartItemModel.findOne({product: item.product, user: userId});
    if (existing) {
      return this.update(existing.id, {quantity: existing.quantity + item.quantity}, userId);
    }

    const newItem = await CartItemModel.create({...item, user: userId});
    await newItem.populate('product');

    return newItem;
  }

  async update(id: string, data: Partial<CartItem>, userId: string): Promise<CartItem> {
    const item = await this._getById(id, userId);
    if (!item) {
      throw new NotFoundError();
    }
    assign(item, data);
    await item.save();

    return item;
  }

  async remove(id: string, userId: string): Promise<void> {
    const item = await this._getById(id, userId);
    if (!item) {
      throw new NotFoundError();
    }
    await item.deleteOne();
  }
}

export default new CartItemService();

api/cart-item/cart-item.controller.ts

import { Request, Response, NextFunction } from 'express';
import cartItemService from './cart-item.service';
import productService from '../product/product.service';
import { CartItem } from './cart-item.entity';
import { TypedRequest } from '../../utils/typed-request.interface';
import { NotFoundError } from '../../errors/not-found';
import { AddCartItemDTO, UpdateQuantityDTO } from './cart-item.dto';

export const list = async (req: Request, res: Response, next: NextFunction) => {
  const user = req.user!;
  const list = await cartItemService.find(user.id!);
  res.json(list);
}

export const add = async (
  req: TypedRequest<AddCartItemDTO>,
  res: Response,
  next: NextFunction) => {
    
    try {
      const user = req.user!;
      const { productId, quantity } = req.body;
      
      const product = await productService.getById(productId);
      if (!product) {
        throw new NotFoundError();
      }
      
      const newItem: CartItem = {
        product: productId,
        quantity
      };
      const saved = await cartItemService.add(newItem, user.id!);
      res.json(saved);
    } catch(err) {
      next(err);
    }
}
  
export const updateQuantity = async (
  req: TypedRequest<UpdateQuantityDTO>,
  res: Response,
  next: NextFunction) => {
    const id = req.params.id;
    
    try {
      const user = req.user!;
      const newQuantity = req.body.quantity;
      
      const updated = await cartItemService.update(id, {quantity: newQuantity}, user.id!);
      res.json(updated);
    } catch(err: any) {
      next(err);
    }
}
    
export const remove = async (req: Request, res: Response, next: NextFunction) => {
  const id = req.params.id;
  try {
    const user = req.user!;
    await cartItemService.remove(id, user.id!);
    res.status(204);
    res.send();
  } catch(err: any) {
    next(err);
  }
}

Aggiungiamo una api per tornare l'utente loggato, farà comodo quando andremo a controllare lato client e useremo i dati per popolare il nome utente e l'immagine nella navbar.

api/user/user.controller.ts

import { NextFunction, Response, Request } from "express";

export const me = async(
  req: Request,
  res: Response,
  next: NextFunction
) => {
  res.json(req.user!);
}

api/user/user.router.ts

import { Router } from "express";
import { me } from "./user.controller";
import { isAuthenticated } from "../../utils/auth/authenticated.middleware";


const router = Router();

router.get('/me', isAuthenticated, me);

export default router;

api/routes.ts

import { Router } from 'express';
import cartItemRouter from './cart-item/cart-item.router';
import productRouter from './product/product.router';
import authRouter from './auth/auth.router';
import userRouter from './user/user.router';

const router = Router();

router.use('/cart-items', cartItemRouter);
router.use('/products', productRouter);
router.use('/users', userRouter);
router.use(authRouter);

export default router;

Client

Da angular dobbiamo assicurarci di mandare il token JWT in ogni richiesta alle nostre api.

Login Page

Iniziamo creando una pagina di login:

jwt.service.ts

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

@Injectable({providedIn: 'root'})
export class JWTService {
  hasToken() {
    return !!this.getToken();
  }

  getToken() {
    return localStorage.getItem('authToken');
  }

  setToken(token: string) {
    localStorage.setItem('authToken', token);
  }

  removeToken() {
    localStorage.removeItem('authToken');
  }
}

auth.service.ts

import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { BehaviorSubject, map, tap } from "rxjs"
import { JWTService } from "./jwt.service";

export interface User {
  id: string;
  firstName: string;
  lastName: string;
  fullName: string;
  picture: string;
}

@Injectable({providedIn: 'root'})
export class AuthService {
  private _currentUser$ = new BehaviorSubject<User | null>(null);

  currentUser$ = this._currentUser$.asObservable();

  constructor(private jwtSrv: JWTService,
              private http: HttpClient,
              private router: Router) {
    this.fetchUser();
  }

  isLoggedIn() {
    return this.jwtSrv.hasToken();
  }

  login(username: string, password: string) {
    return this.http.post<{user: User, token: string}>('/api/login', {username, password})
      .pipe(
        tap(res => this.jwtSrv.setToken(res.token)),
        tap(res => this._currentUser$.next(res.user)),
        map(res => res.user)
      );
  }

  logout() {
    this.jwtSrv.removeToken();
    this._currentUser$.next(null);
    this.router.navigate(['/']);
  }

  private fetchUser() {
    this.http.get<User>('/api/users/me')
      .subscribe(user => this._currentUser$.next(user));
  }
}

login.component.html

<div class="container mt-5">
  <div class="row">
    <div class="col col-lg-4"></div>
    <div class="col col-lg-4 col-md-12 border rounded p-4">
      <form (ngSubmit)="login()" [formGroup]="loginForm">
        <!-- Email input -->
        <div class="form-outline mb-4">
          <label class="form-label">Email address</label>
          <input type="email" class="form-control" formControlName="username" />
        </div>
    
        <!-- Password input -->
        <div class="form-outline mb-4">
          <label class="form-label">Password</label>
          <input type="password" class="form-control" formControlName="password" />
        </div>
        <div class="text-danger" *ngIf="loginError">
          {{ loginError }}
        </div>
    
        <!-- Submit button -->
        <div class="text-center">
          <button type="submit" class="btn btn-primary btn-block mb-4">Sign in</button>
        </div>
      </form>
    </div>
  </div>
</div>

login.component.ts

import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Subject, catchError, takeUntil, throwError } from 'rxjs';
import { AuthService } from 'src/app/services/auth.service';

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

  loginForm = this.fb.group({
    username: ['', {validators: Validators.required}],
    password: ['', {validators: Validators.required}]
  })

  loginError = '';

  private destroyed$ = new Subject<void>();

  constructor(protected fb: FormBuilder,
              private authSrv: AuthService,
              private router: Router) { }

              
  ngOnInit(): void {
    this.loginForm.valueChanges
      .pipe(
        takeUntil(this.destroyed$)
      )
      .subscribe(() => {
        this.loginError = '';
      });
  }

  ngOnDestroy(): void {
    this.destroyed$.next();
    this.destroyed$.complete();
  }

  login() {
    if (this.loginForm.valid) {
      const { username, password } = this.loginForm.value;
      this.authSrv.login(username!, password!)
        .pipe(
          catchError(err => {
            this.loginError = err.error.message;
            return throwError(() => err);   
          })
        )
        .subscribe(() => {
          this.router.navigate(['/products'])
        });
    }
  }
}

Http Interceptor

auth.interceptor.ts

import { HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { JWTService } from '../services/jwt.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(private jwtSrv: JWTService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    const authToken = this.jwtSrv.getToken();

    const authReq = authToken ? req.clone({
      headers: req.headers.set('Authorization', `Bearer ${authToken}`)
    }) : req;

    return next.handle(authReq);
  }
}

app.module.ts

...
  providers: [
   { provide: LOCALE_ID, useValue: 'it-IT' },
   { provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR'},
   { provide: DEFAULT_VAT, useValue: 0},
   { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true},
   CurrencyPipe
  ],
  bootstrap: [AppComponent]
  ...

Se proviamo ad andare nella pagina del checkout vediamo che l'header viene inviato e la richiesta torna i nostri articoli.
Abbiamo però un problema perché nulla vieta all'utente di entrare nella pagina anche senza aver fatto il login. Per limitare l'accesso alle pagine dobbiamo creare una guard e assegnarla nel router.

Guard

ng generate guard guards/auth
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';

export const authGuard: CanActivateFn = () => {
  const router = inject(Router);
  const authSrv = inject(AuthService);

  const isAuthenticated = authSrv.isLoggedIn();

  if (isAuthenticated) {
    return true;
  } else {
    router.navigateByUrl('/login');
    return false;
  }
};

app-routing.module.ts

...
  {
    path: 'checkout',
    canActivate: [authGuard],
    component: CheckoutComponent
  },
...

Non sempre però bastano le guard per gestire l'autenticazione, a volte bisogna andare a nascondere delle funzionalità della pagina o disabilitarle se l'utente non è loggato. Per fare questo possiamo creare delle direttive.

Authenticated Directive

ng generate directive directives/if-authenticated
import { Directive, OnInit, TemplateRef, ViewContainerRef, OnDestroy } from '@angular/core';
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { AuthService } from '../services/auth.service';

@Directive({
  // tslint:disable-next-line: directive-selector
  selector: '[ifAuthenticated]'
})
export class IfAuthenticatedDirective implements OnInit, OnDestroy {
  protected destroyed$ = new Subject<void>();

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef,
    protected authSrv: AuthService
  ) { }

  ngOnInit() {
    this.authSrv.currentUser$
      .pipe(takeUntil(this.destroyed$))
      .subscribe(_ => {
        this.updateView();
      });
  }

  ngOnDestroy() {
    this.destroyed$.next();
    this.destroyed$.complete();
  }

  private updateView() {
    if (this.authSrv.isLoggedIn()) {
        this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}

andiamo a metterlo nel product-card e product-detail per rimuovere i pulsanti di aggiunta se l'utente non è loggato:
product-card.component.ts

...
<div class="card-footer text-muted justify-content-end d-flex" *ifAuthenticated>
...

product-detail.component.html

...
<div class="input-group mb-3 add-form" *ifAuthenticated>
...

Completiamo l'app

Modifichiamo anche cart-source.service.ts, se l'utente non è loggato la lista del carrello deve svuotarsi e non deve fare la richiesta al server.

...
  constructor(private http: HttpClient,
              private authSrv: AuthService) {
    this.authSrv.currentUser$
      .subscribe(user => {
        if (user) {
          this.fetch();
        } else {
          this._items$.next([]);
        }
      })
  }
...

Andiamo a completare la navbar:

ng generate component components/nav-user
<li class="nav-item" ngbDropdown display="dynamic" placement="bottom-end">
  <a class="nav-link p-0" tabindex="0" ngbDropdownToggle role="button">
    <img [src]="user!.picture" class="nav-user-avatar">
    {{user!.fullName}}
  </a>
  <div ngbDropdownMenu aria-labelledby="navbarDropdown3" class="dropdown-menu">
      <a ngbDropdownItem href="#" (click)="logout()">Logout</a>
  </div>
</li>
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { User } from 'src/app/services/auth.service';

@Component({
  selector: 'app-nav-user',
  templateUrl: './nav-user.component.html',
  styleUrls: ['./nav-user.component.css']
})
export class NavUserComponent {
  @Input()
  user: User | null = null;

  @Output('logout')
  logoutEvent = new EventEmitter<void>();

  logout() {
    this.logoutEvent.emit();
  }
}

.nav-user-avatar {
  height: 40px;
  border-radius: 50%;
}

app.component.ts

import { Component } from '@angular/core';
import { VatService } from './services/vat.service';
import { AuthService } from './services/auth.service';


@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  currentUser$ = this.authSrv.currentUser$;
  
  constructor(protected varSrv: VatService,
              protected authSrv: AuthService) {
    let country = 'IT';
    this.varSrv.setCountry(country);
  }

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

app.component.html

<nav class="navbar navbar-dark bg-primary mb-3">
    <div class="container-fluid">
        <a class="navbar-brand" [routerLink]="['/products']">ITS-SHOP</a>
        <ul class="navbar-nav mr-2">
            <app-nav-user
                [user]="currentUser$ | async"
                (logout)="logout()"
                *ngIf="(currentUser$ | async)"></app-nav-user>
            <li class="nav-item" *ngIf="!(currentUser$ | async)">
                <a class="nav-link" [routerLink]="['login']">Login</a>
            </li>
        </ul>
    </div>
</nav>
<div class="container-fluid">
    <router-outlet></router-outlet>
</div>

Esercizio

Implementare una versione più avanzata dell'autenticazione JWT introducendo un refresh token, la possibilità di tenere traccia del login da più dispositivi e il logout automatico di tutti i dispositivi nel caso si provi a utilizzare un refresh token già usato o non valido.

Iniziare col caso base, un refresh token fornito al client al momento del login.
Il flusso è il seguente:
1) al momento del login viene generato sia il token JWT (con scadenza breve) sia un refresh token (sempre token JWT ma con scadenza più lunga)
2) il token JWT viene solo tornato, mentre il refresh token viene salvato a database nella UserIdentity
3) il client salva sia il token che il refresh
4) il client effettua le chiamate come sempre, includendo il token nel server
5) in caso una richiesta autenticata torni 401 viene fatta una chiamata a /api/refreshToken inviando il refreshToken salvato
1) se il refreshToken è ancora valido ed è uguale a quello salvato nel database viene generata una nuova coppia token + refreshToken e vengono inviate al client. Il client salva i nuovi dati e ripete la richiesta originale
2) se il refreshToken è scaduto, non è valido o non combacia la richiesta fallisce, il client elimina i token salvati e rimanda al login. Se il token combacia ma è scaduto va eliminato dal database.
6) al logout vengono eliminati entrambi i token dal client

Aggiungere ora la possibilità di gestire più refreshTokens per consentire il login da diversi dispositivi.
Dovrebbero essere necessarie solo piccole modifiche al codice precedente, per quanto riguarda il flusso ci sono le seguenti variazioni:

  • il server deve tenere traccia a database di una lista di refreshTokens, uno per ogni login attivo
  • quando viene chiamata l'api /api/refreshToken il server deve verificare che il token inviato sia nella lista di quelli salvati, e quando ne genera uno nuovo questo deve andare a sostituire il vecchio token appena utilizzato. Se il token combacia ma è scaduto va eliminato dal database.
  • al momento del logout è necessario chiamare una api /api/logout per andare ad eliminare il refreshToken dalla lista. In alternativa si può fare una procedura per cui al momento del login viene controllata la lista dei refreshToken salvati e viene pulita da tutti quelli scaduti. L'importante è avere un modo per evitare che la lista salvata cresca nel tempo e tenga traccia di dati non più validi.

L'ultimo passo è implementare un sistema base di rilevamento dei furti. Se un refreshToken è valido ma non è nella lista per quell'utente significa che è già stato utilizzato, in questo caso probabilmente c'è stato un furto delle chiavi. Per evitare problemi è meglio eliminare tutti i token salvati per quell'utente in modo da sloggare tutti i dispositivi.
Una volta implementato il controllo si può anche aggiungere l'invio della comunicazione all'utente, invitarlo a resettare le password, a controllare i propri dispositivi, ecc.