Sviluppo fullstack con AdonisJS parte 4: Impostare un sistema di autenticazione

Sviluppo fullstack con AdonisJS parte 4: Impostare un sistema di autenticazione

Sfruttare le funzionalità di AdonisJS per attivare un sistema di autenticazione

Foto di Pavel Nekoranec su Unsplash

Premessa

Questo articolo fa parte di una serie che illustra come costruire un’applicazione con AdonisJS e VueJS. Se ti fossi perso la prima parte inizia da qui.

Introduzione

In questo articolo sfrutteremo le funzionalità di AdonisJS per attivare un sistema di autenticazione e proteggere l’accesso ai contenuti della nostra applicazione.

L’obiettivo finale è profilare gli utenti raggruppandoli in due categorie: amministratori e utenti base. I primi avranno la possibilità di accedere a tutti i contenuti e a tutte le azioni possibili (come eliminare e modificare elementi) mentre gli utenti base potranno caricare dei file collegati al proprio profilo e gestire le proprie informazioni.

Installare Lucid

Per prima cosa installiamo il pacchetto Lucid che ci servirà per interagire facilmente con il database:

npm i @adonisjs/lucid

Configuriamo Lucid:

node ace configure @adonisjs/lucid

Selezioniamo come tipo di database “sqlite”.

Nota: Questo tipo di database è molto comodo per sviluppare e creare prototipi perché non richiede particolari installazioni e il database è salvato in un singolo file. In ambiente di produzione è però sconsigliato utilizzare questo tipo di database, a meno di specifiche esigenze, perché poco adatto a fornire alte performance e a sostenere un elevato numero di richieste simultanee. Grazie a Lucid è anche possibile utilizzare diversi tipi di database a seconda dell’ambiente di lavoro: in sviluppo potremo utilizzare SQLite mentre in produzione PostgreSQL senza sostanziali modifiche al codice.

Infine selezionate l’opzione per aprire le istruzioni nel browser.

Come suggerito dalle istruzioni aprite il file env.ts e aggiungete alle rules la seguente riga:

DB_CONNECTION: Env.schema.string(),

Installare il package auth

Installiamo il package Auth di Adonis per la gestione dell’autenticazione:

npm i @adonisjs/auth

Ora avviamo il processo di configurazione del pacchetto:

node ace configure @adonisjs/auth

Verranno richieste alcune scelte:

  1. Scegliamo come provider “lucid”
  2. Scegliamo come guard “Web”
  3. Chiamiamo il model “User”
  4. Rispondiamo “Y” alla creazione della migrazione

Al termine della procedura saranno creati diversi file; andiamo a vedere nel dettaglio le scelte che abbiamo fatto.

Il provider “lucid” è quello che abbiamo installato nel capitolo precedente e ci permette di interfacciarci al database astraendo dalla tipologia (SQlite, MySQL, PostgreSQL, …) e ottimizzando la scrittura del codice. In alternativa avremmo potuto utilizzare il query builder che utilizza un approccio più vicino alla sintassi SQL.

Il tipo di guard determina come viene verificata l’autenticazione e dove può essere salvata l’informazione di autenticazione. L’opzione “web” crea una sessione basata sui cookie del browser; un’alternativa molto popolare è l’utilizzo dell’auth token che ha il vantaggio di poter essere utilizzato anche in contesti che non hanno a disposizione i cookie (per esempio in un app mobile che vuole autenticarsi al nostro server Adonis).

Il nome del modello è semplicemente il nome che vogliamo dare a quella classe che rappresenterà gli utenti da autenticare.

Notate infine che la configurazione del pacchetto “auth” ha creato in automatico un file di migrazione che contiene già le istruzioni fondamentali per modificare il database aggiungendo la tabella degli utenti.

Installare l’hash driver

L’hash driver rappresenta il meccanismo per trasformare una password in chiaro in una stringa offuscata; questa prassi aggiunge un ulteriore livello di sicurezza nel caso qualcuno riuscisse ad accedere al database. Adonis di default configura “Argon” come hash driver (file config/hash.ts), installiamo questa libreria:

npm i phc-argon2

Migrazioni

Le migrazione servono per tenere traccia delle trasformazioni che avvengono al database. Grazie a queste istruzioni è possibile mantenere allineati i database di diverse istanze della stessa applicazione che potrebbero trovarsi sui computer dei nostri colleghi e/o su ambienti di test e produzione. Ora eseguiamo le migrazioni in modo che sia generato il database SQLite e creata la tabella utenti:

node ace migration:run

Il comando verifica la presenza di file di migrazione ed esegue tutte quelle migrazioni che risultano ancora pendenti.

Nel file creato all’interno di database/migrations trovate le specifiche della tabella utenti inserite dalla configurazione automatica di Adonis. In futuro creeremo delle migrazioni noi stessi e inseriremo nei file delle istruzioni specifiche sulla base delle nostre esigenze.

Seeds

Creiamo un utente di prova con i database seeders ossia dei file simili alle migrazioni che permettono di popolare il database anzi che definirle la struttura. Il nostro primo seeder aggiungerà un utente “admin” e un utente “base” con cui fare le prime prove di autenticazione:

node ace make:seeder User

Apriamo il file generato database/seeders/User.ts e modifichiamolo come segue:

// database/seeders/User.ts
import BaseSeeder from '@ioc:Adonis/Lucid/Seeder'
import User from 'App/Models/User'

export default class UserSeeder extends BaseSeeder {
  public async run() {
    await User.createMany([
      {
        email: '[email protected]',
        password: 'admin',
      },
      {
        email: '[email protected]',
        password: 'base',
      },
    ])
  }
}

Infine eseguiamo i seed:

node ace db:seed

Ora se avete un software per l’interrogazione dei db SQlite (per esempio SQLite Studio) potrete aprire il file tmp/db.sqlite3 e verificare che nella tabella users siano stati aggiunti i due utenti.

adonis_user_table

Nota: i seeder sono adatti per aggiungere velocemente e automaticamente dei record al DB (per esempio un elenco di categorie). Quando create un utente però state mettendo la sua password in chiaro in un file che potrebbe essere accessibile da altre persone tramite il repository quindi, se utilizzate questa tecnica, ricordatevi di cambiare subito password agli utenti creati. In alternativa potete creare gli utenti utilizzando AdonisJS REPL: una linea di comando che accetta codice Adonis.

Creare un utente con REPL

Avviate la console con il comando:

node ace repl

Ora importate il modello User e premete invio

import User from 'App/Models/User'

Creiamo un utente di prova e premete invio

User.create({email: "[email protected]", password: "test"})

Ora troverete nella tabella l’utente “test”

Per uscire da REPL digitate .exit e premete invio.

Esporre gli endpoint per l’autenticazione

Apriamo il file start/routes.ts e modifichiamolo in questo modo:

// start/routes.ts
import Route from '@ioc:Adonis/Core/Route'

Route.get('/', async ({ view }) => {
  return view.render('welcome')
})

Route.post('login', 'AuthController.login')

Route.get('logout', 'AuthController.logout')

Route.group(() => {
  Route.get('about', async ({ auth }) => {
    await auth.use('web').authenticate()
    return [1, 2, 3, 4]
  })
}).middleware('auth')

Abbiamo registrato due nuove route “login” e “logout” a cui potremo accedere senza autenticazione. L’ultima route “about” invece è all’interno di un gruppo a cui applichiamo il middleware “auth” generato in automatica in Middleware/Auth.ts che rifiuta qualunque richiesta non autenticata. “about” sarà quindi accessibile solo previa autenticazione.

“login” e “logout” richiamano i metodi di un controller che non esiste ancora. Creiamo il controller con il comando da terminale:

node ace make:controller AuthController

Apriamo il file generato app/Controllers/Http/AuthController.ts e modifichiamolo così:

// app/Controllers/Http/AuthController.ts
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

export default class Login {
  public async login(ctx: HttpContextContract) {
    const { request, response, auth } = ctx
    const email = request.input('email')
    const password = request.input('password')

    try {
      await auth.use('web').attempt(email, password)
      return {
        id: auth.use('web').user!.id,
        email: auth.use('web').user!.email,
      }
    } catch {
      return response.badRequest('Credenziali non valide')
    }
  }

  public async logout(ctx: HttpContextContract) {
    const { auth } = ctx
    await auth.use('web').logout()
    return 'success'
  }
}

Il metodo login prende i parametri “email” e “password” e prova ad autenticare l’utente ritornando un oggetto con id ed email in caso di esito positivo o errore in caso negativo.

Il metodo logout forza il logout dell’utente attualmente connesso alla sessione.

Infine attiviamo il middleware Auth aggiungendo quanto segue alla fine del file start/kernel.ts:

// start/kernel.ts
Server.middleware.registerNamed({
  auth: () => import('App/Middleware/Auth'),
})

In questo modo le route possono richiamare il middleware attraverso il nome “auth” come abbiamo anticipato nel file routes.ts.

Impostazione delle chiamate da client a server

Installiamo la libreria Axios che semplifica la gestione delle chiamate asincrone.

npm install axios

Ora per comodità rendiamo disponibile Axios come proprietà globale di Vue. Creiamo il file resources/js/services/index.js con il seguente contenuto:

// resources/js/services/index.js
import axios from 'axios'

function createInstance($router) {
  const instance = axios.create()
  instance.interceptors.response.use(
    function (response) {
      return response
    },
    function (error) {
      if (error.response.status === 401) {
        $router.push('/login')
      }
      return Promise.reject(error)
    }
  )
  return instance
}

export function createServices(options) {
  const { router } = options
  return {
    install(app) {
      const instance = createInstance(router)
      app.config.globalProperties.$http = instance
    },
  }
}

Nel file utilizziamo Axios per creare un’istanza (createInstance) personalizzata con la quale è possibile specificare configurazioni di default (ad es. headers da appendere alle richieste) e intercettare richieste e risposte modificandone il contenuto. Sfruttiamo questa capacità di axios per intercettare tutte le risposte del server e, nel caso in cui contengano lo stato 401 “Non autorizzato”, reindirizziamo l’utente alla login.

Nota: il sistema di redirect su chiamata non autorizzata si basa sull’assunto che qualunque dato da proteggere con autenticazione provenga dal server: se ci fosse una pagina che non effettua chiamate al server, infatti, questa sarebbe direttamente visitabile ma il suo contenuto sarebbe unicamente scritto nel codice; nel caso abbiate necessità di proteggere questo tipo di pagine potete forzare una chiamata al server richiedendo un verifica sull’autenticazione prima di mostrare il contenuto anche se il contenuto non è ben protetto perché accessibile da repository e dal codice javascript caricato in pagina.

La funzione createServices crea un oggetto con un metodo install che sarà utilizzato per installare un plugin di Vue con il quale rendere disponibile a livello di applicazione l’istanza di axios sotto la proprietà $http (ho scelto una proprietà che non si riferisca ad Axios perché in futuro potremmo decidere di cambiare libreria senza dover modificare questa proprietà ovunque).

Dentro ad app.js importiamo createServices, lo istanziamo e attiviamo il plugin.

// resources/js/app.js
// ...
import { createServices } from './services'

// ...
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
const services = createServices({ router })

// ... 
app.use(services)

Notate che passiamo router come opzione fino all’istanza di axios per poter forzare il redirect alla pagina di login.

Pagina di login

Creiamo il file resources/js/pages/Login.vue come segue:

<!-- resources/js/pages/Login.vue -->
<script>
import { ref } from 'vue'
import { NSpace, useMessage, NInput, NForm, NFormItem, NButton, NCard } from 'naive-ui'

const emailRegex =
  /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/

export default {
  name: 'Login',
  components: { NSpace, NInput, NForm, NFormItem, NButton, NCard },
  setup() {
    const message = useMessage()
    const formRef = ref(null)
    const formValue = ref({
      email: '',
      password: '',
    })

    return {
      formRef,
      size: ref('large'),
      formValue,
      message,
      rules: {
        email: {
          required: true,
          trigger: ['input', 'blur'],
          validator(rule, value) {
            if (!value) {
              return new Error('Email è obbligatoria')
            }
            if (!emailRegex.test(String(value).toLowerCase())) {
              return new Error('Email non valida')
            }
            return true
          },
        },
        password: {
          required: true,
          message: 'Password è obbligatoria',
          trigger: ['input', 'blur'],
        },
      },
    }
  },
  methods: {
    handleSubmit(e) {
      e.preventDefault()

      this.formRef
        .validate()
        .then(() => {
          this.login()
        })
        .catch((error) => {
          if (!Array.isArray(error)) {
            // this.message.error(error.message)
            return
          }
          error.forEach((fieldErrors) => {
            fieldErrors.forEach((e) => this.message.error(`Error in ${e.field}: ${e.message}`))
          })
        })
    },
    login() {
      const { email, password } = this.formValue
      this.$http
        .post('/login', {
          email,
          password,
        })
        .then((response) => {
          this.$router.push('/')
        })
        .catch((error) => {
          this.message.error(error.response.data)
        })
    },
  },
}
</script>
<template>
  <n-space vertical justify="center" class="wrapper">
    <n-space justify="center">
      <n-card title="Login" class="login-card">
        <n-form :model="formValue" :rules="rules" :size="size" ref="formRef">
          <n-form-item label="Email" path="email">
            <n-input
              v-model:value="formValue.email"
              placeholder="Inserisci il tuo indirizzo email"
              autofocus
              type="email"
            />
          </n-form-item>
          <n-form-item label="Password" path="password">
            <n-input
              placeholder="Inserisci la password"
              v-model:value="formValue.password"
              type="password"
            />
          </n-form-item>
          <n-form-item>
            <n-button @click="handleSubmit" attr-type="submit">Entra</n-button>
          </n-form-item>
        </n-form>
      </n-card>
    </n-space>
  </n-space>
</template>
<style lang="scss" scoped>
.wrapper {
  height: 100vh;
}
.login-card {
  width: 480px;
  box-shadow: 0 0 120px rgba(0, 0, 0, 0.2);
}
</style>

In breve questa pagina mostra un form per la login che all’invio verifica la validità dei dati immessi e richiama l’endpoint “login” esposto lato server. In caso di corretta autenticazione l’utente sarà indirizzato alla home page dell’app.

Layout dell’applicazione

La struttura base dell’applicazione con barra di navigazione, sidebar ecc… non serve nella pagina di login, al contrario potrebbe creare confusione all’utente perché tutti i link e le azioni non sarebbero funzionanti fino a che non si accede. Isoliamo quindi questa struttura in nuovo file resources/js/components/Layout.vue:

<!-- resources/js/components/Layout.vue -->
<script>
import { defineComponent } from 'vue'
import { NSpace, NLayout, NLayoutHeader, NLayoutContent, NLayoutSider } from 'naive-ui'

import Navbar from './Navbar.vue'

export default defineComponent({
  components: {
    NSpace,
    Navbar,
    NLayout,
    NLayoutHeader,
    NLayoutContent,
    NLayoutSider,
  },
})
</script>
<template>
  <n-space vertical size="large">
    <n-layout>
      <n-layout-header bordered>
        <navbar />
      </n-layout-header>
      <n-layout has-sider>
        <n-layout-sider
          collapse-mode="transform"
          show-trigger="arrow-circle"
          content-style="padding: 24px;"
          bordered
          collapsed
        >
          <p>Sidebar</p>
        </n-layout-sider>
        <n-layout-content content-style="padding: 24px;"
          ><router-view></router-view
        ></n-layout-content>
      </n-layout>
    </n-layout>
  </n-space>
</template>

Ora possiamo aggiornare le route client:

  1. aggiungiamo la nuova pagina per il login
  2. attiviamo il layout condiviso nelle pagine protette da autenticazione
// resource/js/routes.js
import Home from './pages/Home'
import About from './pages/About'
import Login from './pages/Login'
import Layout from './components/Layout'

const routes = [
  { path: '/login', component: Login },
  {
    path: '/',
    component: Layout,
    children: [
      { name: 'home', path: '/', component: Home },
      { path: '/about', component: About },
    ],
  },
]

export default routes

Togliamo quindi da App.vue le parti di layout che saranno automaticamente ereditate dalla route:

<!-- resources/js/components/App.vue -->
<script>
import { defineComponent } from 'vue'
import { NConfigProvider, NMessageProvider } from 'naive-ui'

import Layout from './Layout.vue'

/**
 * Use this for type hints under js file
 * @type import('naive-ui').GlobalThemeOverrides
 */
const themeOverrides = {
  common: {
    primaryColor: '#f54646',
    primaryColorHover: '#f52929',
  },
}

export default defineComponent({
  components: {
    NMessageProvider,
    NConfigProvider,
    Layout,
  },
  setup() {
    return {
      themeOverrides,
    }
  },
})
</script>
<template>
  <n-config-provider :theme-overrides="themeOverrides">
    <n-message-provider>
      <router-view />
    </n-message-provider>
  </n-config-provider>
</template>

Ora proviamo a inserire una chiamata al server nella pagina “about” per testare il redirect degli utenti non autorizzati:

<!-- resources/js/pages/About.vue -->
<script>
import { defineComponent, ref } from 'vue'

export default defineComponent({
  data() {
    return {
      data: [],
    }
  },
  mounted() {
    this.$http
      .get('/about')
      .then((response) => {
        this.data = response.data
      })
      .catch((error) => {
        console.log(error)
      })
  },
})
</script>
<template>
  <h1 class="title">About</h1>
  {{ data }}
</template>

La pagina usa la variabile globale $http installata all’avvio dell’applicazione per fare una chiamata all’endpoint “/about” definito nelle route del server. Se tutto funziona correttamente il contenuto della risposta sarà stampato in pagina quando si è autenticati; quando non si è autenticati si verrà reindirizzati automaticamente alla pagina di login.

Logout

Aggiungiamo infine la possibilità di disconnettersi e interrompere l’autenticazione. Modifichiamo il componente Navbar.vue come segue:

<!-- resources/js/components/Navbar.vue -->
<script>
import { defineComponent, h, ref } from 'vue'
import { NButton, NMenu, NIcon } from 'naive-ui'
import { RouterLink } from 'vue-router'
import { Information as WorkIcon, Home as HomeIcon, Logout as LogoutIcon } from '@vicons/carbon'

function renderIcon(icon) {
  return () => h(NIcon, null, { default: () => h(icon) })
}

export default {
  name: 'Navbar',
  components: {
    NButton,
    NMenu,
  },
  data() {
    const menuOptions = [
      {
        label: () =>
          h(
            RouterLink,
            {
              class: 'logo',
              to: {
                name: 'home',
              },
            },
            { default: () => 'Unity' }
          ),
        key: 'logo',
      },
      {
        label: () =>
          h(
            RouterLink,
            {
              to: {
                name: 'home',
              },
            },
            { default: () => 'Go Home' }
          ),
        key: 'home',
        icon: renderIcon(HomeIcon),
      },
      {
        label: () =>
          h(
            RouterLink,
            {
              to: {
                path: '/about',
              },
            },
            { default: () => 'About' }
          ),
        key: 'about',
        icon: renderIcon(WorkIcon),
      },
      {
        label: () =>
          h(
            'a',
            {
              href: '#',
              onClick: (e) => {
                e.preventDefault()
                this.$http.get('/logout').then((response) => {
                  if (response.status === 200) {
                    this.$router.push('/login')
                  }
                })
              },
            },
            'Logout'
          ),
        key: 'logout',
        icon: renderIcon(LogoutIcon),
      },
    ]

    return {
      menuOptions,
      activeKey: ref(null),
    }
  },
}
</script>
<template>
  <n-menu v-model:value="activeKey" mode="horizontal" :options="menuOptions" />
</template>

Potete notare che abbiamo spostato la definizione di menuOptions all’interno del componente per poter richiamare sia $http che $router con i quali forziamo il logout lato server (rendendo persistente lo stato di disconnessione) e reindirizziamo l’utente alla pagina di login.

Ora potete testare cosa accade se visitate l’indirizzo ”/#/about” dopo aver fatto logout.

Il prossimo passo

Nel prossimo articolo svilupperemo una nuova funzionalità dell’applicazione che permetterà di creare, modificare ed eliminare delle categorie di file.

Commenti