Brukeradmin-panel med Gatsby Functions og Auth0

Skjermbilde som viser brukeradmin-dashbord for nettsidene til Boligsameiet Gartnerihagen.

I en serie med artikler har jeg vist hvordan jeg lagde nye nettsider til sameiet ved hjelp av React-rammeverket Gatsby og med brukerautentisering via Auth0. Les del 1 her: Slik bygget jeg nye nettsider til sameiet med Gatsby og Chakra UI

Da de nye nettsidene ble lansert, foregikk all administrasjon av brukere via et temmelig teknisk og innviklet brukergrensesnitt hos Auth0. For at nettsidene til sameiet skal bli en fullverdig løsning som kan overleveres andre etter at jeg går av som styreleder neste år, trengs det imidlertid et mer brukervennlig dashbord som gjør at selv ikke-tekniske brukere kan opprette, oppdatere eller slette brukere.

Min løsning er bygget opp slik:

  • Gatsby på frontend til å lage brukeradmin-dashbordet. Til dashbordet bruker jeg det som kalles client only routes i Gatsby, som jeg har skrevet om her.
  • Auth0 Authentication API for autentisering av brukeren på frontend. Her bruker jeg Auth0 React SDK for Single Page Apps for å gjøre ting litt enklere for meg selv.
  • Gatsby Functions (serverless functions) på backend. Dette er Node-applikasjoner som kjører på serveren og som tar kontakt med Auth0 Management API for å opprette, oppdatere eller slette brukere.

Du finner all kildekoden til hele løsningen på https://github.com/klekanger/gartnerihagen, men i denne artikkelen går jeg gjennom hvordan den er bygget opp — uten at jeg går i detaljer på alt (det ville blitt en bok!).

Hvordan sikre løsningen

Alt som gjøres på klienten kan i utgangspunktet manipuleres. Brukeradministrasjon krever høy sikkerhet, og det å sjekke at noen har tillatelse til å opprette, slette eller oppdatere brukere bør derfor gjøres på en server — ikke på klienten.

Gangen i det hele er som følger:

  • Brukeren logger inn på klienten og mottar et access token fra Auth0
  • Når brukeren besøker brukeradmin-dashbordet, sendes access-tokenet til en serverless function hos Netlify som 1) sjekker at det er et gyldig access token, 2) tar kontakt med Auth0 og sjekker at access-tokenet tilhører en bruker med de nødvendige rettighetene til å gjøre hva det nå måtte være som skal gjøres fra brukeradmin-dashbordet
  • Hvis brukeren har de nødvendige rettighetene tar serverless-funksjonen kontakt med Auth0 sitt Management API som returnerer en oversikt over alle brukerne.

For å få tilgang til brukeradmin-dashbordet i min løsning, må brukeren ha rollen "admin". Jeg bruker Auth0 sin løsning for rollebasert aksesskontroll (RBAC) til å definere tre ulike roller: "user", "editor" og "admin". Avhengig av rolle, vil den innloggede brukeren få opp knapper for brukeradministrasjon og/eller redigering av innhold når vedkommende går inn på "Min side" på sameiets nettsider:

Når brukeren besøker "Min side" vil det dukke opp knapper for å gå til brukeradministrasjon eller redigering av innhold hvis brukeren har de riktige rollene.
Når brukeren besøker "Min side" vil det dukke opp knapper for å gå til brukeradministrasjon eller redigering av innhold hvis brukeren har de riktige rollene.

Her er en forenklet skisse av hvordan det fungerer:

Skisse som viser hvordan frontenden mottar et access token fra Auth0. Dette access-tokenet brukes så av vår Gatsby Function for å verifisere at brukeren har nødvendige tilganger for å administrere brukere.

Gatsby Functions gjør det enkelt å lage API-er

Da jeg startet prosjektet med å lage brukeradmin-dashbordet, begynte jeg å lage API-ene for å hente, oppdatere eller opprette brukere med Netlify Functions. Jeg var godt igang med dette, da Gatsby annonserte at de hadde lansert Gatsby Functions. Plutselig hadde Gatsby innebygget støtte for serverless functions, og jobben min ble enda enklere. Dette er noe Next.js har hatt lenge, så velkommen etter, Gatsby!

Å opprette en Gatsby Function er så enkelt som å opprette en JavaScript- eller TypeScript-fil i mappen src/api/ og eksportere en handler-funksjon som tar to parametere — req (request) og res (response). For de som har vært borti Node-rammeverket Express, er Gatsby Functions temmelig likt.

Hello World-eksempelet i Gatsbys offisielle dokumentasjon illustrerer hvor enkelt det kan gjøres:

1// src/api/hello-world.js
2
3export default function handler(req, res) {
4  res.status(200).json({ hello: `world` })
5}

Gjør du et kall til URL-en /api/hello-world returnerer serverless-funksjonen { hello: 'world' }, og HTTP-statuskode 200 (som betyr at alt er i orden).

Fire API-er

Jeg fant ut at jeg trengte fire API-er. Hvert API er én serverless-funksjon:

1src
2├── api
3│   └── admin-users
4│       ├── create-user.ts
5│       ├── delete-user.ts
6│       ├── get-users-in-role.ts
7        └── update-user.ts

Når brukere besøker brukeradmin-nettsiden (som de får tilgang til via "Min side" kalles API-et get-users-in-role. Forutsatt at den innloggede brukeren har rettighetene i orden, returnerer API-et en liste over samtlige brukere, inkludert rollene til hver av brukerne. Hver bruker vises som et kort på brukeradmin-dashbordet, med knapper for å endre bruker, slette bruker eller bytte passord for brukeren:

Skjermbilde av brukeradmin-grensesnittet.
Søkefeltet og dropdown-menyen lar deg filtrere ut kun de brukerne du er interessert i.

Gjøre klart ting hos Auth0

Det var ganske mye som måtte skrus sammen for å få hele maskineriet til å fungere som det skulle. Før jeg kunne lage mine egne backend-API-er med Gatsby Functions for administrasjon av brukere måtte jeg sette opp en del ting hos Auth0 først.

Først måtte jeg opprette en ny såkalt "maskin-til-maskin"-applikasjon hos Auth0. Dette er applikasjoner som ikke skal kommunisere med klienter, kun med en annen server du stoler på (som serverless-funksjonene jeg ville lage for brukeradministrasjon).

Når jeg logger meg inn på manage.auth0.com og går inn på Applications finner jeg disse to applikasjonene:

Skjermbilde som viser to applikasjoner hos Auth0: Backend og Boligsameiet Gartnerihagen.

Den som heter Boligsameiet Gartnerihagen er den som tar seg av den vanlige autentiseringen for brukere som er logget inn på nettsiden. Den som heter Backend er maskin-til-maskin-applikasjonen som skal brukes av vår serverless Gatsby-funksjon som kjører på serverne til Netlify.

For å få satt opp rollebaserte tillatelser (RBAC) må vi opprette et nytt API hos Auth0 hvor vi legger inn alle tillatelsene (scopes) vi skal kunne gi brukere basert på hvilke roller brukeren har. Dette er tillatelsene Auth0s Management API krever for å kunne utføre ulike operasjoner, og som vi senere kan velge blant når vi oppretter de ulike rollene for brukerne (i vårt tilfelle admin, user eller editor).

Jeg kalte mitt API for Useradmin, og la inn de ulike tillatelsene jeg kom til å trenge for oppdatering av brukere og roller. Auth0 har en nærmere beskrivelse av gangen i det hele her.

Skjermbilde som viser API-er hos Auth0, og hvordan vi har definert tillatelser for API-et Useradmin.

Når dette var gjort, ga jeg maskin-til-maskin-applikasjonen Backend tilgang til både Auth0 sitt Management API og det nye Useradmin-API-et jeg nettopp lagde.

Skjermbilde som viser Auth0 Backend-applikasjonen som har fått tilgang til Auth0 Management API og Useradmin API.

Dette er imidlertid ikke nok — i tillegg må du trykke den lille nedoverpilen lengst til høyre på hvert av API-ene (Auth0 Management API og Useradmin) for å gi Backend-applikasjonen de nødvendige tillatelsene for hvert API. Jeg huket av alle rettighetene som jeg hadde definert for Useradmin-API-et.

Skjermbilde: Vi har krysset av i sjekkboksene for alle tillatelsene Backend-applikasjonen skal ha tilgang til.

Deretter måtte jeg sette opp de ulike bruker-rollene ved å velge User Management fra hovedmenyen hos Auth0 og så velge Roles. Jeg opprettet de tre rollene, admin, editor og user, og for hver av rollene valgte jeg Add permissions, deretter hvilket API jeg ville legge til tillatelser fra — i mitt tilfelle Useradmin.

Skjermbilde: Vi har definert tre roller hos Auth0: Admin, user og editor.

For Admin-rollen la jeg til samtlige tillatelser som var definert i Useradmin-API-et. Rollene editor og user trengte ingen tillatelser — dette er roller jeg kun sjekker for på klienten for å avgjøre om hvorvidt knapper for å redigere innhold på nettsiden skal vises eller ikke. Brukere som har admin-rollen er de eneste som vil få tillatelse av Gatsby-funksjonen jeg har laget til å ta kontakt med Auth0 Management API (som igjen vil sjekke at brukeren faktisk har de nødvendige tillatelsene).

Skjermbilde: Admin-rollen har fått alle tillatelsene.

For å slippe unødvendige API-kall og forenkle koden på klientsiden, ønsket jeg også å gjøre det mulig å se hvilke roller en bruker har når brukeren logger seg inn. Dette for å kunne vise roller på Min side, i tillegg til å vise knapper for brukeradministrasjon og innholdsredigering kun for de som har riktige roller. Som standard vil access-tokenet kun inneholde alle tillatelsene brukeren har fått (gjennom sin rolle), men navnet på rollen vil ikke ligge i metadataene i access-tokenet. Det må vi fikse.

Auth0 har noe som kalles Flows og Actions som gjør det mulig å gjøre ulike ting for eksempel i forbindelse med innlogging, brukerregistrering eller annet. Jeg valgte den "flowen" som heter Login, og valgte deretter å legge til en "action" som skal komme etter at brukeren logger seg inn og før access-tokenet er sendt.

Når du oppretter en ny action hos Auth0 får du opp en kodeeditor. Jeg la inn følgende kodesnutt, som sørger for å legge til alle rollene til brukeren i access-tokenet som sendes til klienten:

1/**
2 * @param {Event} event - Details about the user and the context in which they are logging in.
3 * @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
4 */
5exports.onExecutePostLogin = async (event, api) => {
6  const namespace = 'https:/gartnerihagen-askim.no';
7  if (event.authorization) {
8    api.idToken.setCustomClaim(`${namespace}/roles`, event.authorization.roles);
9    api.accessToken.setCustomClaim(`${namespace}/roles`, event.authorization.roles);
10  }
11}

Beskrivelse av dette, og flere eksempler på hva actions kan brukes til finner du hos Auth0, koden over er hentet herfra.

Hent en liste over alle brukere

Omsider kan vi begynne å lage brukeradmin-dashbordet som skal kjøre på klienten. Vi starter med hovedsiden, den som viser alle registrerte brukere. Komponentene for å redigere brukere og slette brukere skal jeg gå gjennom i neste artikkel.

På frontenden opprettet jeg komponenten userAdminPage.tsx som returnerer brukergrensesnittet med en boks øverst med informasjon om hvem som er logget inn, et tekstfelt for å filtrere/søke etter brukere, samt en dropdown-meny for å velge om man ønsker å vise alle brukere eller kun administratorer eller redaktører. Dette var ganske rett frem å lage, takket være ferdige komponenter i Chakra UI.

Jeg lagde så en custom hook (useGetAllUsers.js) som tar kontakt med get-users-in-role-API-et og sender med access-tokenet til den innloggede brukeren. Custom-hooken returnerer variablene data, loading og error, samt funksjonen getToken som brukes hvis Auth0 trenger den innloggede brukerens tillatelse til at Auth0 kan aksessere brukerkontoen. Dette er noe nye brukere vil bli spurt om første gang.

Hvis loading = true viser jeg min egen custom LoadingSpinner-komponent med en melding til brukeren.

1const { data, loading, error, getToken } = useGetAllUsers();
2
3if (loading) {
4  return (
5    <LoadingSpinner spinnerMessage='Kobler til brukerkonto-administrasjon' />
6  );
7}

Så snart get-users-in-role-API-et er ferdig med å hente alle brukerne, finner vi alle brukerne i data.body.users og jeg bruker array-metoden .filter til å filtrere ut kun de brukerne jeg vil vise (basert på hva jeg har tastet inn i søkefeltet). Og så sorteres alle navnene med .sort før jeg løper over dem med .map og presenterer brukerne som hvert sitt kort på skjermen.

Før vi kommer så langt, har det imidlertid skjedd litt backend-magi i Gatsby-funksjonen get-users-in-role. Først bruker vi biblioteket @serverless-jwt/jwt-verifier for å lese access-tokenet som klienten nettopp sendte da den gjorde en GET-request til get-users-in-role. Dette er altså access-tokenet til den brukeren som er logget inn på klienten. Etter å ha sjekket at det er et gyldig token med jwt.verifyAccessToken, henter vi ut hvilke tillatelser som ligger i dette tokenet, og sjekker at det er de tillatelsene som trengs. Hvilke tillatelser brukeren må ha for å utføre ulike operasjoner er godt beskrevet i dokumentasjonen til Auth0 sitt Management API og i dokumentasjonen til ManagementClient-SDK-et jeg bruker for å gjøre jobben enklere for meg selv.

Her er første del av koden til serverless-funksjonen, den del av koden som sjekker rettigheter etc.:

1// api/admin-users/get-users-in-role.ts
2
3import { GatsbyFunctionRequest, GatsbyFunctionResponse } from 'gatsby';
4const ManagementClient = require('auth0').ManagementClient;
5const {
6  JwtVerifier,
7  JwtVerifierError,
8  getTokenFromHeader,
9} = require('@serverless-jwt/jwt-verifier');
10
11const jwt = new JwtVerifier({
12  issuer: `https://${process.env.GATSBY_AUTH0_DOMAIN}/`,
13  audience: `https://${process.env.AUTH0_USERADMIN_AUDIENCE}`,
14});
15
16export default async function handler(
17  req: GatsbyFunctionRequest,
18  res: GatsbyFunctionResponse
19) {
20  let claims, permissions
21  const token = getTokenFromHeader(req.headers.authorization);
22
23  if (req.method !== `GET`) {
24    return res.status(405).json({
25      error: 'method not allowed',
26      error_description: 'You should do a GET request to access this',
27    });
28  }
29
30  // Verify access token
31  try {
32    claims = await jwt.verifyAccessToken(token);
33    permissions = claims.permissions || [];
34  } catch (err) {
35    if (err instanceof JwtVerifierError) {
36      return res.status(403).json({
37        error: `Something went wrong. ${err.code}`,
38        error_description: `${err.message}`,
39      });
40    }
41  }
42
43  // check if user should have access at all
44  if (!claims || !claims.scope) {
45    return res.status(403).json({
46      error: 'access denied',
47      error_description: 'You do not have access to this',
48    });
49  }
50
51  // Check the permissions
52  if (!permissions.includes('read:roles')) {
53    return res.status(403).json({
54      error: 'no read access',
55      status_code: res.statusCode,
56      error_description:
57        'Du må ha admin-tilgang for å administrere brukere. Ta kontakt med styret.',
58      body: {
59        data: [],
60      },
61    });
62  }
63.
64.
65.

Roller i Auth0 fungerer ved at du definerer de rollene du ønsker (i vårt tilfelle "user", "editor", "administrator"). Så definerer du hvilke rettigheter hver rolle skal ha. Til slutt tilegner du en eller flere roller til brukerne.

Tidligere lagret man roller i et eget app_metadata-felt i access-tokenet for hver bruker, men Auth0 har gått over til en ny løsning for rollebasert autentisering som gjør at vi ikke lenger får rollene levert sammen med dataene for hver enkelt bruker. Det gjorde jobben med å hente alle brukere og rollene for hver bruker, ganske mye mer tungvint. Jeg endte opp med å bygge følgende get-users-in-role-API:

  • Bruke Auth0 sitt ManagementClient-SDK til å opprette en ny ManagementClient som vi kaller auth0.
  • Nå som vi har en ManagementClient som heter auth0, kan vi bruke auth0.getRoles() for å hente alle tilgjengelige roller vi har definert hos Auth0. Vi får da et array med rollene user, admin og editor (vi kunne selvfølgelig hardkodet dette, men ved å bruke getRoles-metoden er løsningen fleksibel og vil fortsatt fungere hvis vi senere skulle finne på å opprette nye roller hos Auth0.
  • Vi bruker .map til å lage enda et nytt array som inneholder alle brukerne innenfor hver rolle. Det gjør vi med auth0.getUsersInRole der vi sender inn ID-en til hver av rollene vi hentet med getRoles.
  • Nå har vi et array som heter userRoles som inneholder alle de tre rollene, med alle brukerne innenfor hver rolle. Hvis en bruker har to roller (f.eks. er både editor og admin), vil brukeren finnes flere steder.
1[
2        {
3            "role": "admin",
4            "users": [
5                {
6                    "user_id": "auth0|xxx",
7                    "email": "kurt@lekanger.no",
8                    "name": "Kurt Lekanger"
9                }
10            ]
11        },
12        {
13            "role": "editor",
14            "users": [
15                {
16                    "user_id": "auth0|xxx",
17                    "email": "kurt@lekanger.no",                    
18                    "name": "Kurt Lekanger"
19                },
20                {
21                    "user_id": "auth0|yyy",
22                    "email": "kurt@testesen.xx",                    
23                    "name": "Kurt Testesen"
24                },
25						]
26				}
27... og så videre!
28]
29

Det vi er ute etter er imidlertid et array med alle brukerne, der hver bruker eksisterer bare én gang som et objekt som igjen inneholder et array med alle rollene i. Vi må derfor bygge opp et nytt array som jeg har kalt userListWithRoles. Først henter jeg samtlige brukere som er registrert i databasen hos Auth0 med const userList = await auth0.getUsers(). Så bruker jeg forEach med en nøstet for-loop inni for å gå over én og én bruker og sjekke én og én rolle og om brukeren eksisterer i brukerlisten for denne rollen. Hver rolle brukeren har, blir lagt inn i et roles-array for denne brukeren.

En skisse som illustrerer hvordan det fungerer, og hvilke metoder i ManagementClient-SDK-et som brukes:

Skisse som viser hvordan vi bygger opp et nytt array med brukere, der hver bruker har et array med alle rollene til brukeren.

Til slutt returnerer jeg userListWithRoles fra API-et og HTTP-statuskode 200 for å indikere at alt gikk som det skulle. Veldig forkortet eksempel på hva som returneres fra API-et — legg merke til at hver bruker nå har fått et roles-array:

1  body: {
2    users: [
3      {
4        name: 'Kurt Lekanger',
5        email: "kurt@lekanger.no",
6        user_id: 'auth0|xxxx',
7        roles: ['admin', 'editor', 'user'],
8      },
9      {
10        name: 'Kurt Testesen',
11				email: "kurt@testesen.xx",
12        user_id: 'auth0|yyyy',
13        roles: ['editor', 'user'],
14      },
15    ],
16  },

I virkeligheten inneholder hvert brukerobjekt i userListWithRoles-arrayet også en masse andre metadata fra Auth0, som bl.a. når brukeren var logget inn sist, epostadresse, om eposten er verifisert, osv.

Her er resten av kildekoden til get-users-in-role-API-et:

1// // api/admin-users/get-users-in-role.ts 
2.
3.
4.
5  const auth0 = new ManagementClient({
6    domain: `${process.env.GATSBY_AUTH0_DOMAIN}`,
7    clientId: `${process.env.AUTH0_BACKEND_CLIENT_ID}`,
8    clientSecret: `${process.env.AUTH0_BACKEND_CLIENT_SECRET}`,
9    scope: 'read:users read:roles read:role_members',
10  });
11
12  try {
13    const roles: string[] | undefined = await auth0.getRoles();
14    const allUsersInRoles = await roles.map(async (role: any) => {
15      const usersInRole = await auth0.getUsersInRole({ id: role.id });
16      return { role: role.name, users: usersInRole };
17    });
18
19    const userRoles = await Promise.all(allUsersInRoles); // Get a list of all the roles and the users within each of them,
20    const userList = await auth0.getUsers(); // and a list of every registered user
21
22    let userListWithRoles = [];
23    userList.forEach((user) => {
24      for (let i = 0; i < userRoles.length; i++) {
25        if (
26          userRoles[i].users.find((element) => element.user_id === user.user_id)
27        ) {
28          const existingUserToModify = userListWithRoles.find(
29            (element) => element.user_id === user.user_id
30          );
31          if (existingUserToModify) {
32            existingUserToModify.roles = [
33              ...existingUserToModify.roles,
34              userRoles[i].role, 
35            ];
36          } else {
37            userListWithRoles.push({
38              ...user,
39              roles: [userRoles[i].role],
40            });
41          }
42        }
43      }
44    });
45
46    res.status(200).json({
47      body: {
48        users: userListWithRoles,
49      },
50    });
51  } catch (error) {
52    res.status(error.statusCode || 500).json({
53      body: {
54        error: error.name,
55        status_code: error.statusCode || 500,
56        error_description: error.message,
57      },
58    });
59  }
60}

I neste artikkel skal jeg gå gjennom hvordan jeg lagde komponentene for å opprette, oppdatere eller slette brukere.

Les neste del: Brukeradmin-dashbord med Gatsby Functions: Oppdatere, opprette eller slette brukere

Du finner all kildekoden til hele løsningen på https://github.com/klekanger/gartnerihagen

Denne YouTube-videoen viser hvordan brukergrensesnittet og nettsidene ser ut live: https://youtu.be/XzkTRw5D5mg

Publisert: 01. september 2021 (oppdatert: 15. juli 2022)