Sviluppo fullstack con AdonisJS parte 7: Gestione dei privilegi

Sviluppo fullstack con AdonisJS parte 7: Gestione dei privilegi

Diversificare le possibili azioni a seconda della tipologia di utente connesso

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 andiamo ad isolare i permessi degli utenti in modo che alcuni possano agire su tutte le sezioni (admin) mentre altri abbiano un accesso parziale (utente base).

In particolare vogliamo che gli utenti amministratori possano continuare a fare tutto mentre gli altri utenti non possano creare, modificare o eliminare le categorie: potranno solamente leggerne l’elenco e i dettagli.

Identificare gli utenti admin

In un sistema complesso sarebbe consigliabile utilizzare un nuovo modello “group” utilizzato per raggruppare logicamente gli utenti (admin, basic user, …) in modo che in futuro sia possibile ampliare l’elenco dei gruppi e affinare la gestione dei privilegi. In questo caso però ho pensato di semplificare il concetto limitandoci a distinguere gli utenti amministratori con un flag nella tabella utenti stessa; ciò non toglie che in futuro si possa comunque eseguire un refactoring che sostituisca al flag la presenza dei gruppi utente.

Aggiungiamo quindi una colonna is_admin alla tabella utente. Questa colonna sarà valorizzata a true nel caso l’utente sia un amministratore.

Creiamo la migrazione:

node ace make:migration add_is_admin_to_users

Modifichiamo il file generato per creare una nuova colonna di tipo boolean:

// app/database/migrations/*_add_is_admin_to_users.ts
import BaseSchema from '@ioc:Adonis/Lucid/Schema'

export default class AddIsAdminToUsers extends BaseSchema {
  protected tableName = 'users'

  public async up() {
    this.schema.alterTable(this.tableName, (table) => {
      table.boolean('is_admin')
    })
  }

  public async down() {
    this.schema.alterTable(this.tableName, (table) => {
      table.dropColumn('is_admin')
    })
  }
}

Eseguiamo la migrazione:

node ace migration:run

Aggiungiamo ora la colonna al modello User:

// app/Models/User.ts
export default class User extends BaseModel {
  
  // ...
  
  @column()
  public isAdmin?: boolean

  // ...
}

Ora identifichiamo il nostro utente “admin” come amministratore creando un seed:

node ace make:seeder Admin

Modifichiamo il file generato come segue:

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

export default class AdminSeeder extends BaseSeeder {
  public async run() {
    const admin = await User.findBy('email', '[email protected]')
    if (admin) {
      admin.isAdmin = true
      await admin.save()
    }
  }
}

All’esecuzione del seed cerchiamo l’utente identificandolo tramite email e aggiorniamo la sua proprietà isAdmin a true. Di default la proprietà sarà null quindi possiamo limitarci ad assegnare il valore agli utenti che devono essere amministratori (in javascript il controllo logico su null è pari a false come vedremo più avanti).

Infine eseguiamo il seed specificando il file:

node ace db:seed --files "./database/seeders/Admin.ts"

Controllare l’accesso

Ora che gli utenti amministratori hanno una proprietà che li identifica possiamo controllare nel codice quali azioni possono essere compiute dai diversi tipi di utente (lettura, modifica, eliminazione, …). In questo caso il rischio con il tempo è di riempire il codice di istruzioni sui permessi difficili da mantenere, ma per fortuna Adonis ha pronto un modulo anche per questo tipo di problematica.

Installiamo il pacchetto @adonisjs/bouncer:

npm i @adonisjs/bouncer

Lanciamo la configurazione:

node ace configure @adonisjs/bouncer

Con bouncer possiamo descrivere delle azioni e il loro livello di accesso, per esempio potremmo creare un’azione che rappresenta la creazione delle categorie dei file e darvi accesso solo agli utenti admin.

// codice di esempio: non utilizzare
import User from 'App/Models/User'

export const { actions } = Bouncer
  .define('createFileCategories', (user: User) => {
    return user.is_admin
  })

Il controllo sull’azione si potrebbe poi applicare nel relativo controller:

// codice di esempio: non utilizzare
// ...
public async store(ctx: HttpContextContract) {
  await bouncer.authorize('createFileCategories')

  const { request } = ctx

  const payload = await this.validate(request)

  const category = new FileCategory()
  category.name = payload.name
  await category.save()

  return category.id
}
// ...

L’utente connesso viene automaticamente passato all’azione definita e, nel caso in cui la callback di createFileCategories, ritorni false il metodo lancerà immediatamente un’eccezione che impedirà agli utenti non amministratori di creare una nuova categoria.

Con i metodi bouncer.allows() e bouncer.denies() possiamo verificare i permessi su un’azione senza lanciare eccezioni ma ottenendo un boolean come risposta.

Generazione delle policy

Per ordinare meglio il codice utilizziamo le policy di bouncer che permettono di isolare in un file le istruzioni di accesso ad ogni risorsa. Iniziamo creando le policy per le categorie dei file:

node ace make:policy FileCategory

Risponderemo come segue ai prompt:

  • Enter the name of the resource model to authorize · FileCategory
  • Enter the name of the user model to be authorized · User
  • Select the actions you want to authorize · create, update, delete

Come suggerito dal comando apriamo il file start/bouncer.ts e modifichiamo la seguente istruzione in fondo al file:

// start/bouncer.ts

// ...

export const { policies } = Bouncer.registerPolicies({
  FileCategoryPolicy: () => import('App/Policies/FileCategoryPolicy'),
})

Il comando ha generato il file app/Policies/FileCategoryPolicy.ts che andiamo a modificare come segue:

// app/Policies/FileCategoryPolicy.ts
import { BasePolicy } from '@ioc:Adonis/Addons/Bouncer'
import User from 'App/Models/User'

export default class FileCategoryPolicy extends BasePolicy {
  public async create(user: User) {
    return !!user.isAdmin
  }
  public async update(user: User) {
    return !!user.isAdmin
  }
  public async delete(user: User) {
    return !!user.isAdmin
  }
}

Verifichiamo quindi che nelle tre azioni di modifica delle categorie l’utente sia admin. Notate che la proprietà isAdmin contiene il valore “1” e quindi va convertita in boolean con l’operatore !!.

Modifichiamo i metodi interessati del controller in modo che verifichino le relative policy:

// app/Controllers/Http/FileCategoriesController.ts
export default class FileCategoriesController {  
	  // ...
  
  public async store(ctx: HttpContextContract) {
    const { request, bouncer } = ctx

    await bouncer.with('FileCategoryPolicy').authorize('create')

    const payload = await this.validate(request)

    const category = new FileCategory()
    category.name = payload.name
    await category.save()

    return category.id
  }

  public async destroy(ctx: HttpContextContract) {
    const { request, bouncer } = ctx

    await bouncer.with('FileCategoryPolicy').authorize('delete')

    const id = request.param('id')
    const category = await FileCategory.findOrFail(id)
    await category.delete()
    return 'success'
  }

  // ...

  public async update(ctx: HttpContextContract) {
    const { request, bouncer } = ctx

    await bouncer.with('FileCategoryPolicy').authorize('update')

    const id = request.param('id')

    const payload = await this.validate(request)
    const category = await FileCategory.findOrFail(id)

    category.name = payload.name
    category.save()
    return category
  }
}

In tutti e tre i casi estraiamo la proprietà bouncer dal contesto della chiamata e la utilizziamo specificando la policy e verificando l’autorizzazione sulla relativa azione.

Ora se provate ad accedere con l’utente “admin” riuscirete ad eseguire ancora tutte le azioni (eliminare, creare e modificare categorie) mentre se accedete come “base” vedrete un messaggio di errore che impedisce ogni tipo di modifica.

Controllo lato client

I controlli di accesso e autorizzazione è fondamentale che siano fatti lato server perché è l’unico strato dell’applicazione realmente sicuro. Una volta che ci siamo assicurati che lato server tutto sia sotto controllo allora possiamo aggiungere una serie di controlli anche lato client che semplifichino l’interfaccia all’utente evitando di farlo incappare inutilmente in errori su azioni che comunque non potrebbe compiere.

Andiamo ora a recuperare le informazioni del profilo utente in seguito alla login e quindi a nascondere le funzionalità a cui l’utente non può avere accesso.

Nel controller AuthController, quando l’utente inserisci i dati corretti, restituiamo al client le informazioni sull’utente connesso:

// app/Controllers/Http/AuthController.ts

// ...

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,
        isAdmin: auth.use('web').user!.isAdmin,
      }
    } catch {
      return response.badRequest('Credenziali non valide')
    }
  }

//...

Lato client vogliamo condividere l’informazione dell’utente autenticato a tutti i livelli dell’applicazione (in tutti i componenti). Questo risultato si può ottenere con il Provide / Inject di VueJS o con un sistema di gestione dello stato come Vuex. Nel nostro caso, useremo Pinia che è la naturale evoluzione di Vuex:

npm install pinia

Attiviamo Pinia nell’app:

// resources/js/app.js
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { createPinia } from 'pinia'

import 'vfonts/Inter.css'
import '../css/app.css'

import App from './components/App'
import routes from './routes'
import { createServices } from './services'

const app = createApp(App)

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

app.use(router)
app.use(services)
app.use(createPinia())

const vm = app.mount('#app')

Creiamo il nostro primo store che conterrà le informazioni dell’utente autenticato nel file resources/js/stores/user.js:

// resources/js/stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => {
    return {
      isAuthenticated: false,
      email: '',
      id: 0,
      isAdmin: false,
    }
  },
  actions: {
    setUser(user) {
      this.isAuthenticated = true
      this.email = user.email
      this.isAdmin = user.isAdmin
      this.id = user.id
    },
  },
})

Alla login avvenuta con successo salviamo nello store le informazioni utente:

// resources/js/pages/Login.vue
<script>
// ...
import { mapActions } from 'pinia'

import { useUserStore } from '../stores/user'
  
export default {
// ...
  methods: {
    ...mapActions(useUserStore, ['setUser']),
    // ...
    login() {
        const { email, password } = this.formValue
        this.$http
          .post('/login', {
            email,
            password,
          })
          .then(({ data }) => {
            this.setUser({
              id: data.id,
              email: data.email,
              isAdmin: !!data.isAdmin,
            })
            this.$router.push('/')
          })
          .catch((error) => {
            this.message.error(error.response.data)
          })
      },
      // ...
  }
  // ...
}
</script>

Ora nella Sidebar possiamo verificare se l’utente è admin prima di caricare le voci di menù per la gestione delle categorie dei file:

<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,
  Bookmark as BookmarkIcon,
  Add as AddIcon,
  List as ListIcon,
} from '@vicons/carbon'
import { mapState } from 'pinia'

import { useUserStore } from '../stores/user'

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

export default {
  name: 'Sidebar',
  components: { NButton, NMenu },
  data() {
    return {
      ...mapState(useUserStore, ['isAdmin']),
    }
  },
  computed: {
    menuOptions() {
      const menuOptions = []
      if (this.isAdmin()) {
        menuOptions.push({
          label: 'Categorie file',
          key: 'file-categories',
          icon: renderIcon(BookmarkIcon),
          children: [
            {
              label: () =>
                h(
                  RouterLink,
                  {
                    to: {
                      path: '/file-categories',
                    },
                  },
                  { default: () => 'Elenco categorie' }
                ),
              key: 'file-categories-list',
              icon: renderIcon(ListIcon),
            },
            {
              label: () =>
                h(
                  RouterLink,
                  {
                    to: {
                      path: '/file-categories/create',
                    },
                  },
                  { default: () => 'Crea categoria' }
                ),
              key: 'file-categories-create',
              icon: renderIcon(AddIcon),
            },
          ],
        })
      }

      return menuOptions
    },
  },
}
</script>
<template>
  <n-menu :collapsed-width="64" :collapsed-icon-size="22" :options="menuOptions" />
</template>

Abbiamo spostato la definizioni delle opzioni del menù sotto a computed in modo che reagiscano dinamicamente al mutare dei valori dello store mappati in data.

Commenti