Autenticación

<< Creación del proyecto Listado de productos >>

Servicio de autenticación (AuthService)

Dentro de la carpeta de autenticación (auth), lo primero que haremos será un servicio llamado AuthService donde gestionaremos la autenticación de los usuarios (login, registro, logout, y comprobación del token). Tendremos además, una propiedad de tipo signal (boolean) llamada logged que indicará si el usuario está logueado o no, y al ser una señal permitirá reaccionar a los cambios de valor fácilmente.

Para almacenar el token en el almacenamiento local, instalaremos el plugin @capacitor/preferences, de forma que en dispositivos móviles no utilice localStorage, ya que la persistencia no está garantizada, sino los mecanismos nativos de cada sistema (Android, iOS) como vimos en la sección Capacitor del curso.

npm i @capacitor/preferences

ng g i auth/interfaces/user

ng g i auth/interfaces/responses

ng g service auth/services/auth

import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Preferences } from '@capacitor/preferences';
import { Observable, catchError, from, map, of, switchMap } from 'rxjs';
import { User } from '../interfaces/user';
import { TokenResponse, UserResponse } from '../interfaces/responses';

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  #logged = signal(false);

  #http = inject(HttpClient)

  get logged() {
    return this.#logged.asReadonly();
  }

  login(
    email: string,
    password: string,
    firebaseToken?: string // En el futuro se usará para notificaciones Push
  ): Observable<void> {
    return this.#http
      .post<TokenResponse>('auth/login', {
        email,
        password,
        firebaseToken,
      })
      .pipe(
        // SwitchMap permite trabajar con funciones que devuelven observables o promesas
        switchMap(async (r) => { // Función async, devuelve promesa (Promise<void>)
          try {
            await Preferences.set({ key: 'fs-token', value: r.accessToken });
            this.#logged.set(true);
          } catch (e) {
            throw new Error('Can\'t save authentication token in storage!');
          }
        })
      );
  }

  register(user: User): Observable<void> {
    return this.#http.post<void>('auth/register', user);
  }

  async logout(): Promise<void> {
    await Preferences.remove({ key: 'fs-token' });
    this.#logged.set(false);
  }

  isLogged(): Observable<boolean> {
    if (this.#logged()) { // Estamos logueados
      return of(true);
    }
    // from transforma una promesa en un observable
    return from(Preferences.get({ key: 'fs-token' })).pipe(
      switchMap((token) => {
        if (!token.value) { // No hay token
          return of(false);
        }

        return this.#http.get('auth/validate').pipe(
          map(() => {
            this.#logged.set(true);
            return true; // Todo correcto
          }),
          catchError(() => of(false)) // Token no válido
        );
      }),
    );
  }


  getProfile(): Observable<User> {
    return this.#http
      .get<UserResponse>('auth/profile')
      .pipe(map((r) => r.user));
  }
}

En el servicio AuthService hay ciertas diferencias respecto a una aplicación Angular de navegador al utilizar Preferences (promesas) en lugar de LocalStorage (síncrono) para gestionar el token de autenticación:

  • En el método login el servidor devuelve un token que procesaremos guardándolo en Preferences. Al trabajar Preferences con promesas, para que no devuelva un dato de tipo Observable<Promise<void>>, utilizamos switchMap, que permite cambiar el resultado del observable inicial al resultado de otro observable o promesa, en este caso de la función async donde guardamos el token que devuelve una promesa. 
  • Pasa algo similar con el método isLogged, ya que tenemos que obtener el token primero (promesa) para saber si existe y llamar al servicio que comprueba el token (observable). En este caso hemos transformado la promesa de Preferences.get a observable con la función from. Utilizamos switchMap para intercambiar el resultado de este observable por el de la llamada al servidor.

Interceptores

A continuación crearemos 2 interceptores, uno llamado baseUrl que inyectará en todas las peticiones al servidor la ruta del mismo y otro llamado auth que si está presente, agregará a la petición la cabecera Authorization con el token de acceso.

ng g interceptor interceptors/base-url

ng g interceptor interceptors/auth

import { HttpInterceptorFn } from '@angular/common/http';

export const baseUrlInterceptor: HttpInterceptorFn = (req, next) => {
  const server = "http://localhost:3000"; // Pon la url del servidor aquí
  const reqClone = req.clone({
    url: `${server}/${req.url}`,
  });
  return next(reqClone);
};

Finalmente registramos los interceptores junto al servicio HttpClient en el archivo main.ts.

//...
bootstrapApplication(AppComponent, {
  providers: [
    //...
    provideHttpClient(withInterceptors([baseUrlInterceptor, authInterceptor])),
  ],
});

Guardianes de rutas

Como es útil tener un mecanismo para controlar a qué páginas de la aplicación puede acceder o no un usuario logueado, crearemos 2 guardianes (guards) de tipo CanActivate para las rutas. Uno llamado login-activate que permitirá acceder a una ruta solo cuando el usuario está logueado, y otro llamado logout-activate para lo contrario, permitir acceder a rutas como el login o registro solo a usuarios no autenticados.

ng g guard guards/login-activate

ng g guard guards/logout-activate

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/services/auth.service';
import { map } from 'rxjs';

export const loginActivateGuard: CanActivateFn = (route, state) => {
  const router = inject(Router);
  return inject(AuthService)
    .isLogged()
    .pipe(map((logged) => logged || router.createUrlTree(['/auth/login'])));
};

Finalmente, activamos los guardianes en el archivo de rutas principales de la aplicación (app.routes.ts).

import { Routes } from '@angular/router';
import { logoutActivateGuard } from './guards/logout-activate.guard';
import { loginActivateGuard } from './guards/login-activate.guard';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'auth/login',
    pathMatch: 'full',
  },
  {
    path: 'auth',
    loadChildren: () =>
      import('./auth/auth.routes').then((m) => m.authRoutes),
    canActivate: [logoutActivateGuard]
  },
  {
    path: 'products',
    loadChildren: () =>
      import('./products/products.routes').then((m) => m.productsRoutes),
    canActivate: [loginActivateGuard]
  },
];

Vamos a quitar lo que no necesitemos y hacer unas pequeñas modificaciones en el menú lateral. El menú lateral está en app.component.html (componente ion-menu). Después quitaremos el encabezado del menú (ion-list-header, ion-note) para poner en su lugar un elemento ion-item al principio del menú que muestre los datos del usuario logueado.

Además, podemos quitar el array de labels y la lista que los muestra, ya que no tienen ninguna funcionalidad en nuestro ejemplo. En la lista de enlaces del menú, dejaremos solo uno por ahora: /products (quita también todas las importaciones de iconos excepto la de home). Sobre los iconos, en la plantilla vamos a quitar la distinción entre Android (md) y iOS (ios) y pondremos siempre el mismo icono usando [name] para simplificar. De esta manera no tenemos que importar la versión outline y sharp de cada icono que usemos.

En las páginas de login y registro (usuario no logueado) vamos a deshabilitar el menú (propiedad disabled) para que no aparezca ni haciendo el gesto con el dedo (desplazamiento a la derecha), ya que aunque no pongamos botón de menú, si este está activo, puede aparecer con este gesto. Vamos a vincular que el menú esté habilitado a que los datos del usuario autenticado estén disponibles

Haremos una función effect vinculada a la signal logged del servicio AuthService, y que cada vez que cambie obtendremos los datos del usuario (si está logueado), o lo pondremos a null si se ha desconectado para que se desactive el menú.

Otra cosa que vamos a tener en cuenta es la utilización de los plugins de Capacitor para ocultar la imagen SplashScreen y cambiar el color de la barra de notificaciones nativa en cuanto la aplicación esté preparada (Platform.isReady).

npm i @capacitor/status-bar

npm i @capacitor/splash-screen

<ion-app>
  <ion-split-pane contentId="main-content">
    <ion-menu contentId="main-content" type="overlay" [disabled]="!user()">
      @if (user()) {
        <ion-item color="tertiary">
          <ion-avatar slot="start">
            <ion-img [src]="user()!.avatar"></ion-img>
          </ion-avatar>
          <ion-label>
            <h3>{{user()!.name}}</h3>
            <p>{{user()!.email}}</p>
          </ion-label>
        </ion-item>
      }
      <ion-content>
        <ion-list>
          @for (p of appPages; track $index) {
            <ion-menu-toggle auto-hide="false">
              <ion-item routerDirection="root" [routerLink]="[p.url]" lines="none" detail="false" routerLinkActive="selected">
                <ion-icon aria-hidden="true" slot="start" [name]="p.icon"></ion-icon>
                <ion-label>{{ p.title }}</ion-label>
              </ion-item>
            </ion-menu-toggle>
          }
        </ion-list>
      </ion-content>
    </ion-menu>
    <ion-router-outlet id="main-content"></ion-router-outlet>
  </ion-split-pane>
</ion-app>

Página de login

Para crear una página para gestionar el login de usuarios, utilizaremos el siguiente comando:

ionic g page auth/login

A continuación añadimos la ruta a la página de login en el archivo de rutas auth.routes.ts:

import { Routes } from "@angular/router";

export const authRoutes: Routes = [
  {
    path: 'login',
    loadComponent: () => import('./login/login.page').then(m => m.LoginPage),
  }
];

Ionic, al menos en la versión 8, cuando creas un nuevo componente o página, importa el módulo de Angular CommonModule, que incluye directivas como ngClass, ngIf, etc. Por eficiencia, vamos a quitar esa importación, y ya importaremos si necesitamos, las directivas correspondientes por separado. 

Por ahora no redirigiremos a la ruta /products hasta que la implementemos.

<ion-header [translucent]="true">
  <ion-toolbar color="primary">
    <ion-title>Login</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <form (ngSubmit)="login()" #loginForm="ngForm">
    <ion-list>
      <ion-item>
        <ion-input type="email" name="email" label="Email" labelPlacement="floating" required email [(ngModel)]="email"></ion-input>
      </ion-item>
      <ion-item>
        <ion-input type="password" name="password"  label="Password" labelPlacement="floating" required [(ngModel)]="password"></ion-input>
      </ion-item>
    </ion-list>
    <ion-grid>
      <ion-row>
        <ion-col>
          <ion-button type="submit" color="primary" expand="block" [disabled]="loginForm.invalid">
            <ion-icon name="log-in" slot="start"></ion-icon>
            Login
          </ion-button>
        </ion-col>
        <ion-col>
          <ion-button color="tertiary" expand="block" [routerLink]="['/auth/register']" [routerDirection]="'root'">
            <ion-icon name="document-text" slot="start"></ion-icon>
            Register
          </ion-button>
        </ion-col>
      </ion-row>
    </ion-grid>
  </form>
</ion-content>

No hay que olvidar registrar los iconos necesarios, en este caso logIn y documentText en AppComponent.

Página de registro

A continuación crearemos una página para dar de alta nuevos usuarios. Esta página tendrá un formulario con los datos de un usuario y cuando el registro se complete con éxito, redirigirá a la página de login.

ionic g page auth/register

Después añadimos la ruta a la página de registro en el archivo de rutas auth.routes.ts:

import { Routes } from "@angular/router";

export const authRoutes: Routes = [
  //...
  {
    path: 'register',
    loadComponent: () => import('./register/register.page').then(m => m.RegisterPage),
  },
];

Como en la página de login, quitaremos la importación de CommonModule.

Para elegir la imagen de avatar, utilizaremos la cámara, por lo que instalaremos el plugin de Capacitor correspondiente (camera) junto a pwa-elements para poder probarlo desde el navegador. Para habilitar los componentes PWA de Ionic debemos registrarlos en el archivo main.ts llamando a defineCustomElements(window).

npm i @capacitor/camera

npm i @ionic/pwa-elements

En esta página pondremos un formulario con la información del usuario que vamos a registrar. Crearemos también un validador para comprobar si los campos de la contraseña tienen el mismo valor. Si no queremos tener que utilizar el prefijo app en el selector, debemos dejarlo a cadena vacía tanto en el archivo angular.json (propiedad "prefix") como en la configuración de ESLint (.eslintrc.json).

ng g d validators/valueEquals

El método registerOnValidatorChange permite a Angular registrar una función que podemos llamar cada vez que cambie un valor que reciba la directiva (Input) para que vuelva a validar el input que contiene el validador. Si no, la validación solo se recalcular cuando se edita el valor del campo, por lo que si tenemos 2 contraseñas iguales y luego modificamos el primer campo para que sean diferentes, no nos marcaría el segundo campo como invalid.

<ion-header [translucent]="true">
  <ion-toolbar color="primary">
    <ion-title>Register</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <form #registerForm="ngForm" (ngSubmit)="register()">
    <ion-list>
      <ion-item>
        <ion-input name="name" label="Name" labelPlacement="floating" required [(ngModel)]="user.name" #nameModel="ngModel"></ion-input>
        @if (nameModel.dirty && nameModel.valid) {
        <ion-icon name="checkmark-circle" slot="end" color="success"></ion-icon>
        }
      </ion-item>
      <ion-item>
        <ion-input type="email" name="email" label="Email" labelPlacement="floating" required email [(ngModel)]="user.email" #emailModel="ngModel"></ion-input>
        @if (emailModel.dirty && emailModel.valid) {
        <ion-icon name="checkmark-circle" slot="end" color="success"></ion-icon>
        }
      </ion-item>

      <ion-item>
        <ion-input type="password" name="password" label="Password" labelPlacement="floating" minlength="4" required [(ngModel)]="user.password" #passModel="ngModel"></ion-input>
        @if (passModel.dirty && passModel.valid) {
        <ion-icon name="checkmark-circle" slot="end" color="success"></ion-icon>
        }
      </ion-item>
      <ion-item>
        <ion-input type="password" name="password2" label="Repeat password" labelPlacement="floating" required [valueEquals]="user.password!" [(ngModel)]="password2" #passModel2="ngModel"></ion-input>
        @if (passModel2.dirty && passModel2.valid) {
        <ion-icon name="checkmark-circle" slot="end" color="success"></ion-icon>
        }
      </ion-item>
      <ion-item>
        <ion-label position="inset">Avatar</ion-label>
        <ion-button color="secondary" (click)="takePhoto()">
          <ion-icon name="camera" slot="start"></ion-icon>
          Camera
        </ion-button>
        <ion-button color="tertiary" (click)="pickFromGallery()">
          <ion-icon name="images" slot="start"></ion-icon>
          Gallery
        </ion-button>
      </ion-item>
    </ion-list>
    @if (user.avatar) {
    <ion-img [src]="user.avatar"></ion-img>
    }
    <ion-grid>
      <ion-row>
        <ion-col>
          <ion-button type="submit" color="primary" expand="block" [disabled]="registerForm.invalid || !user.avatar">
            <ion-icon name="document-text" slot="start"></ion-icon>
            Register
          </ion-button>
        </ion-col>
        <ion-col>
          <ion-button color="danger" expand="block" fill="outline" [routerLink]="['/auth/login']" [routerDirection]="'root'">
            <ion-icon name="arrow-undo-circle" slot="start"></ion-icon>
            Cancel
          </ion-button>
        </ion-col>
      </ion-row>
    </ion-grid>
  </form>
</ion-content>

Importante: No te olvides de importar y registrar los iconos arrowUndoCircle, camera, images, y checkmarkCircle en app.component.ts.

Al usar una aplicación zoneless, como la obtención de la imagen en base64 es una operación asíncrona, Angular no detectará los cambios posteriores a dicha operación por defecto. Como modificamos una propiedad interna del objeto, es más sencillo utilizar el servicio ChangeDetectorRef para avisar a Angular de los cambios que envolver los datos del usuario en una signal.

<< Creación del proyecto Listado de productos >>