How To Do Multi Model Authentication with AdonisJS and Lucid ORM

In this lesson, we'll learn how to set up multi-model authentication using a User and Admin model with AdonisJS and Lucid ORM.

Published
Aug 13, 22
Duration
18m 29s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

I’ve had several people ask me how to go about authentication within AdonisJS using Lucid using multiple models, specifically a User and Admin model. So, today we’ll take some time to walk through how to do exactly that, and I think you’ll find it a rather easy feat.

Starting Point

If you’d like to follow along I have a repository on GitHub called to serve as a starting point for this lesson, you can find it here, or use the checkout command below. You can also find the completed code here if that’s what you’re after.

git clone https://github.com/adocasts/lucid-multi-model-authentication.git

If you clone down the above, be sure to checkout the 01_StartingPoint branch to follow along.

Within this application, you’ll find an /auth/admin and /auth/user page. The user page will contain a login and registration form. The admin page will contain a login form, though later in this lesson we’ll add the registration form to keep things simple (knowing you likely won’t want any ol’ person to be able to register as an admin).

Installing & Configuring Authentication

First, let’s get the AdonisJS Auth package installed and configured. While we’re installing this, let's also get our hashing package installed, called phc-argon2.

npm i @adonisjs/auth phc-argon2

With those installed, let’s next configure the AdonisJS Auth package within our project.

node ace configure @adonisjs/auth

# ❯ Select provider for finding user · lucid
# ❯ Select which guard you need for authentication · web
# ❯ Enter model name to be used for authentication · User
# ❯ Create migration for the users table? · true

Creating Our Admin Model & Migration

So, we created our user model and migration above with our auth configuration. Next, we’ll want to get the same structure created for Admin. You can copy/paste both your User model and users migration as your Admin model and admins migration. But, to keep things easier to follow along with in written format, we’ll go ahead and create these using the Ace CLI.

node ace make:model Admin -m

The above will get our Admin model created, but thanks to the addition of -m it’ll also create a migration for this model as well.

Admin Migration

Let’s go ahead and duplicate the columns from our users migration for our admins migration. If you’re copying/pasting from your users migration, remember to update the table name to admins.

// database/migrations/TIMESTAMP_admins.ts

import BaseSchema from '@ioc:Adonis/Lucid/Schema'

export default class UsersSchema extends BaseSchema {
  protected tableName = 'admins'

  public async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id').primary()
      table.string('email', 255).notNullable()
      table.string('password', 180).notNullable()
      table.string('remember_me_token').nullable()

      /**
       * Uses timestampz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp('created_at', { useTz: true }).notNullable()
      table.timestamp('updated_at', { useTz: true }).notNullable()
    })
  }

  public async down() {
    this.schema.dropTable(this.tableName)
  }
}

Admin Model

We’ll also want to do the same for our Admin model. If you’re copying/pasting from your User model, remember to update the class name to Admin and to update the hashPassword type to Admin as well.

// app/Models/Admin.ts

import { DateTime } from 'luxon'
import Hash from '@ioc:Adonis/Core/Hash'
import { column, beforeSave, BaseModel } from '@ioc:Adonis/Lucid/Orm'

export default class Admin extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column()
  public email: string

  @column({ serializeAs: null })
  public password: string

  @column()
  public rememberMeToken?: string

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime

  @beforeSave()
  public static async hashPassword (admin: Admin) {
    if (admin.$dirty.password) {
      admin.password = await Hash.make(admin.password)
    }
  }
}

Run Migrations

Our migrations are now good to go, so let's go ahead and run them.

node ace migration:run

# ❯ migrated database/migrations/TIMESTAMP_users
# ❯ migrated database/migrations/TIMESTAMP_admins

Creating Our Authentication Guards

With our database now set up, let's now focus on our authentication system by first getting our guards defined.

Auth Configuration

First, let’s get our auth configuration file setup. This file is what defines the default authentication guard (the guard property) and specific configurations for each guard our application uses. Most notably, this is where we’ll define what model each guard should use.

So, our default guard is web, which is what we defined when we configured authentication within our project. To help make things a tad easier to understand, let's rename this to user. So, with this change, when we want to login using the User model we’ll use the user guard. This will also be our default guard as well.

// config/auth.ts

const authConfig: AuthConfig = {
  guard: 'user', // 👈 change from 'web' to 'user'
  guards: {
    /*
    |--------------------------------------------------------------------------
    | Web Guard
    |--------------------------------------------------------------------------
    |
    | Web guard uses classic old school sessions for authenticating users.
    | If you are building a standard web application, it is recommended to
    | use web guard with session driver
    |
    */
    user: { // 👈 change from web to user
      driver: 'session',

      provider: {
        /*
        |--------------------------------------------------------------------------
        | Driver
        |--------------------------------------------------------------------------
        |
        | Name of the driver
        |
        */
        driver: 'lucid',

        /*
        |--------------------------------------------------------------------------
        | Identifier key
        |--------------------------------------------------------------------------
        |
        | The identifier key is the unique key on the model. In most cases specifying
        | the primary key is the right choice.
        |
        */
        identifierKey: 'id',

        /*
        |--------------------------------------------------------------------------
        | Uids
        |--------------------------------------------------------------------------
        |
        | Uids are used to search a user against one of the mentioned columns. During
        | login, the auth module will search the user mentioned value against one
        | of the mentioned columns to find their user record.
        |
        */
        uids: ['email'],

        /*
        |--------------------------------------------------------------------------
        | Model
        |--------------------------------------------------------------------------
        |
        | The model to use for fetching or finding users. The model is imported
        | lazily since the config files are read way earlier in the lifecycle
        | of booting the app and the models may not be in a usable state at
        | that time.
        |
        */
        model: () => import('App/Models/User'),
      },
    }
}

You’ll notice TypeScript will begin to complain about this whole section, don’t worry we’ll fix that in a moment.

Next, let’s copy/paste the user key and its object value and remap this for our Admin model by changing the pasted key name to admin and the model return value import to App/Models/Admin. To help prevent confusion, below is the entire auth configuration file with the changes noted.

// config/auth.ts

/**
 * Config source: <https://git.io/JY0mp>
 *
 * Feel free to let us know via PR, if you find something broken in this config
 * file.
 */

import { AuthConfig } from '@ioc:Adonis/Addons/Auth'

/*
|--------------------------------------------------------------------------
| Authentication Mapping
|--------------------------------------------------------------------------
|
| List of available authentication mapping. You must first define them
| inside the `contracts/auth.ts` file before mentioning them here.
|
*/
const authConfig: AuthConfig = {
  guard: 'user',
  guards: {
    /*
    |--------------------------------------------------------------------------
    | Web Guard
    |--------------------------------------------------------------------------
    |
    | Web guard uses classic old school sessions for authenticating users.
    | If you are building a standard web application, it is recommended to
    | use web guard with session driver
    |
    */
    user: {
      driver: 'session',

      provider: {
        /*
        |--------------------------------------------------------------------------
        | Driver
        |--------------------------------------------------------------------------
        |
        | Name of the driver
        |
        */
        driver: 'lucid',

        /*
        |--------------------------------------------------------------------------
        | Identifier key
        |--------------------------------------------------------------------------
        |
        | The identifier key is the unique key on the model. In most cases specifying
        | the primary key is the right choice.
        |
        */
        identifierKey: 'id',

        /*
        |--------------------------------------------------------------------------
        | Uids
        |--------------------------------------------------------------------------
        |
        | Uids are used to search a user against one of the mentioned columns. During
        | login, the auth module will search the user mentioned value against one
        | of the mentioned columns to find their user record.
        |
        */
        uids: ['email'],

        /*
        |--------------------------------------------------------------------------
        | Model
        |--------------------------------------------------------------------------
        |
        | The model to use for fetching or finding users. The model is imported
        | lazily since the config files are read way earlier in the lifecycle
        | of booting the app and the models may not be in a usable state at
        | that time.
        |
        */
        model: () => import('App/Models/User'),
      },
    },

    admin: { // 👈 update pasted key to admin
      driver: 'session',

      provider: {
        /*
        |--------------------------------------------------------------------------
        | Driver
        |--------------------------------------------------------------------------
        |
        | Name of the driver
        |
        */
        driver: 'lucid',

        /*
        |--------------------------------------------------------------------------
        | Identifier key
        |--------------------------------------------------------------------------
        |
        | The identifier key is the unique key on the model. In most cases specifying
        | the primary key is the right choice.
        |
        */
        identifierKey: 'id',

        /*
        |--------------------------------------------------------------------------
        | Uids
        |--------------------------------------------------------------------------
        |
        | Uids are used to search a user against one of the mentioned columns. During
        | login, the auth module will search the user mentioned value against one
        | of the mentioned columns to find their user record.
        |
        */
        uids: ['email'],

        /*
        |--------------------------------------------------------------------------
        | Model
        |--------------------------------------------------------------------------
        |
        | The model to use for fetching or finding users. The model is imported
        | lazily since the config files are read way earlier in the lifecycle
        | of booting the app and the models may not be in a usable state at
        | that time.
        |
        */
        model: () => import('App/Models/Admin'), // 👈 update pasted import to Admin
      },
    },
  },
}

export default authConfig

With that, our auth configuration is ready! Next, let’s get these TypeScript errors fixed up by updating the type definitions.

Auth Contract

The auth contract module contains type definitions for our authentication providers and our guards. We made these definitions out-of-date with our prior changes, so all we need to do is update web to user and duplicate user for our admin. Essentially the same process we went through for our auth configuration.

Guards List

First, let's fix up our GuardsList, this is at the bottom of our auth contract file. Here we’ll just need to update two web usages to user.

// contracts/auth.ts

declare module '@ioc:Adonis/Addons/Auth' {
  interface ProvidersList {
    // ... 
  } 

  /*
  |--------------------------------------------------------------------------
  | Guards
  |--------------------------------------------------------------------------
  |
  | The guards are used for authenticating users using different drivers.
  | The auth module comes with 3 different guards.
  |
  | - SessionGuardContract
  | - BasicAuthGuardContract
  | - OATGuardContract ( Opaque access token )
  |
  | Every guard needs a provider for looking up users from the database.
  |
  */
  interface GuardsList {
    /*
    |--------------------------------------------------------------------------
    | Web Guard
    |--------------------------------------------------------------------------
    |
    | The web guard uses sessions for maintaining user login state. It uses
    | the `user` provider for fetching user details.
    |
    */
  web: {
  user: {
    implementation: SessionGuardContract<'user', 'web'>
    implementation: SessionGuardContract<'user', 'user'>
      config: SessionGuardConfig<'user'>
    }
  }
}

The first argument to the SessionGuard types are for the provider to use within the ProvidersList, by default this is user, so we’ll leave that as-is. However, the second argument is the guard key name to use and that we will want to update from web to user.

Let’s go ahead and duplicate our user guard for our admin guard, replacing user with admin everywhere within the duplicated contents. Below will be our GuardsList after this change is made.

// contracts/auth.ts

interface GuardsList {
  user: {
    implementation: SessionGuardContract<'user', 'user'>
    config: SessionGuardConfig<'user'>
  },

  admin: {
    implementation: SessionGuardContract<'admin', 'admin'>
    config: SessionGuardConfig<'admin'>
  }
}

Providers List

Next, we’ll get our ProvidersList updated and all we need to do here is duplicate the user provider definition for our admin. To make things easy to understand, below is our complete auth contract file after this change has been made.

// contracts/auth.ts

/**
 * Contract source: <https://git.io/JOdz5>
 *
 * Feel free to let us know via PR, if you find something broken in this
 * file.
 */

import User from 'App/Models/User'
import Admin from 'App/Models/Admin'

declare module '@ioc:Adonis/Addons/Auth' {
  /*
  |--------------------------------------------------------------------------
  | Providers
  |--------------------------------------------------------------------------
  |
  | The providers are used to fetch users. The Auth module comes pre-bundled
  | with two providers that are `Lucid` and `Database`. Both uses database
  | to fetch user details.
  |
  | You can also create and register your own custom providers.
  |
  */
  interface ProvidersList {
    /*
    |--------------------------------------------------------------------------
    | User Provider
    |--------------------------------------------------------------------------
    |
    | The following provider uses Lucid models as a driver for fetching user
    | details from the database for authentication.
    |
    | You can create multiple providers using the same underlying driver with
    | different Lucid models.
    |
    */
    user: {
      implementation: LucidProviderContract<typeof User>
      config: LucidProviderConfig<typeof User>
    },

    admin: {
      implementation: LucidProviderContract<typeof Admin>
      config: LucidProviderConfig<typeof Admin>
    }
  }

  /*
  |--------------------------------------------------------------------------
  | Guards
  |--------------------------------------------------------------------------
  |
  | The guards are used for authenticating users using different drivers.
  | The auth module comes with 3 different guards.
  |
  | - SessionGuardContract
  | - BasicAuthGuardContract
  | - OATGuardContract ( Opaque access token )
  |
  | Every guard needs a provider for looking up users from the database.
  |
  */
  interface GuardsList {
    /*
    |--------------------------------------------------------------------------
    | User Guard
    |--------------------------------------------------------------------------
    |
    | The web guard uses sessions for maintaining user login state. It uses
    | the `user` provider for fetching user details.
    |
    */
    user: {
      implementation: SessionGuardContract<'user', 'user'>
      config: SessionGuardConfig<'user'>
    },

    admin: {
      implementation: SessionGuardContract<'admin', 'admin'>
      config: SessionGuardConfig<'admin'>
    }
  }
}

With this in place, you should now be able to go back into your auth configuration and see all the TypeScript errors are fixed up.

Below is the gist of what we just set up:

  • To login/register with the User model, use the user guard

  • To login/register with the Admin model, use the admin guard

Auth Validator

Let's take a brief aside to make our user and admin authentication a tad easier by creating a generic AuthValidator.

node ace make:validator Auth

All we need to do is add a simple check for an email and a password.

// app/Validators/AuthValidator.ts

public schema = schema.create({
  email: schema.string([rules.email()]),
  password: schema.string()
})

Registration

Next, let's get our registrations set up for both our User and Admin. I’ll show the controller method for both below, then discuss the difference.

// User Registration
// app/Controllers/Http/AuthUserController.ts

public async register({ request, response, auth }: HttpContextContract) {
  const data = await request.validate(AuthValidator)
  const user = await User.create(data)

  await auth.login(user)

  return response.redirect('/')
}
// Admin Registration
// app/Controllers/Http/AuthAdminController.ts

public async register({ request, response, auth }: HttpContextContract) {
  const data = await request.validate(AuthValidator)
  const admin = await Admin.create(data)

  await auth.use('admin').login(admin)

  return response.redirect('/')
}

The first thing you’ll notice is that our user registration uses the User model to create the user record within our database while our admin registration uses the Admin model.

Next, you’ll notice our admin registration calls an additional use('admin') method prior to calling login. This use method is how we specify which authentication guard we want the auth module to use. Since the user guard is set up as our default we can omit this for our user registration, but we need to specify to use the admin guard for our admin registration.

Login Routes & FOrms

Let’s add route definitions for both of these and rig them up to the forms.

// start/routes.ts

Route.post('/auth/user/register', 'AuthUserController.register').as('auth.user.register')
Route.post('/auth/admin/register', 'AuthAdminController.register').as('auth.admin.register')
{{-- resources/views/auth/user.edge --}}

@layout('layouts/main')

@section('content')

  <main class="flex flex-wrap justify-center mt-24">
    @!form.login({ title: 'User Login' })

    {{-- 👇 add the action: route('auth.user.register') here --}}
    @!form.register({ title: 'User Registration', action: route('auth.user.register') })

    <div class="w-full text-center mt-8">
      <a href="{{ route('auth.admin.index') }}" class="text-blue-600">
        Admin? Login here instead.
      </a>
    </div>
  </main>

@endsection
{{-- resources/views/auth/admin.edge --}}

@layout('layouts/main')

@section('content')

  <main class="flex flex-wrap justify-center mt-24">
    @!form.login({ title: 'Admin Login' })

    {{-- 👇 add the register form here along with the action --}}
    @!form.register({ title: 'Admin Registration (for simplicity)', action: route('auth.admin.register') })

    <div class="w-full text-center mt-8">
      <a href="{{ route('auth.user.index') }}" class="text-blue-600">
        User? Login or register here instead.
      </a>
    </div>
  </main>

@endsection

Silent Auth Middleware

The SilentAuth middleware will perform a check to see if there is a currently authenticated user or not using your default auth guard. Let’s go ahead and add this guard globally for our project so that our auth.user will auto-populate for us.

// start/kernel.ts

Server.middleware.register([
  () => import('@ioc:Adonis/Core/BodyParser'),
  () => import('App/Middleware/SilentAuth') // 👈 add SilentAuth here
])

Next, let's update the SilentAuth middleware to account for our admin guard.

// app/Middleware/SilentAuth.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

/**
 * Silent auth middleware can be used as a global middleware to silent check
 * if the user is logged-in or not.
 *
 * The request continues as usual, even when the user is not logged-in.
 */
export default class SilentAuthMiddleware {
  /**
   * Handle request
   */
  public async handle({ auth }: HttpContextContract, next: () => Promise<void>) {
    /**
     * Check if user is logged-in or not. If yes, then `ctx.auth.user` will be
     * set to the instance of the currently logged in user.
     */

    // check to see if admin is login
    if (await auth.use('admin').check()) {
      // admin is logged in
      // lets set the requests default guard to 'admin'
      auth.defaultGuard = 'admin'
    }

    await auth.check()
    await next()
  }
}

Here, we’ll perform and if check on our admin guard to see if an administrator is currently authenticated. If this returns truthy, we know an admin is logged in for this request. So, we’re safe to set this request’s default guard to admin.

Otherwise, the request will continue onward as usual for our default user guard.

By updating the default guard when an admin is logged in, we’re simplifying the logic needed to perform a logout, as you see next.

Logout

Since we’re updating the defaultGuard to admin whenever an administrator is authenticated, we’re clear to use auth.logout() as-is without needing to worry about applying it to a specific guard. We’ve offloaded that check globally to our SilentAuth middleware.

So, since logout will be a generic call for both our auth guards, let’s create a generic route within our routes.ts file.

// start/routes.ts

Route.get('/auth/logout', async ({ response, auth }) => {
  await auth.logout()

  return response.redirect('/')
}).as('auth.logout')

On the Edge side, hop into the welcome.edge file and replace the auth check within the if statement and the logout button with the below.

{{-- resources/views/welcome.edge --}}

@layout('layouts/main')

@section('content')

  <main class="flex flex-wrap justify-center items-center h-full">
    @if (auth.user)
      <h3>{{ auth.user.email }}</h3>
      {{--  Logged In  --}}
      <a href="{{ route('auth.logout') }}">
        Logout
      </a>
    @else
      {{--  Logged Out  --}}
      <div class="flex flex-col space-y-3 items-center">
        <a href="{{ route('auth.user.index') }}" class="text-blue-600">User Authentication</a>
        <a href="{{ route('auth.admin.index') }}" class="text-blue-600">Admin Authentication</a>
      </div>
    @endif
  </main>

@endsection

Logging In

Lastly, is logging in. This will be fairly similar to our registration changes. Again, I’ll put both the user and admin login controller methods below, then describe the differences.

// User Login
// app/Controllers/Http/AuthUserController.ts

public async login({ request, response, auth }: HttpContextContract) {
  const { email, password } = await request.validate(AuthValidator)

  await auth.attempt(email, password)

  return response.redirect('/')
}
// Admin Login
// app/Controllers/Http/AuthAdminController.ts

public async login({ request, response, auth }: HttpContextContract) {
  const { email, password } = await request.validate(AuthValidator)

  await auth.use('admin').attempt(email, password)

  return response.redirect('/')
}

As you can see, the only difference here is the specification on which guard to use when attempting to login. Note again, our user guard is our default guard so we don’t need to call the use('user') there, though you absolutely could if you prefer the specificity!

Login Routes & Forms

Next, let’s get the routes setup

// start/routes.ts

Route.post('/auth/user/login', 'AuthUserController.login').as('auth.user.login')
Route.post('/auth/admin/login', 'AuthAdminController.login').as('auth.admin.login')

Then, let’s get them added to our user and admin auth pages.

{{-- resources/views/auth/user.edge --}}

@layout('layouts/main')

@section('content')

  <main class="flex flex-wrap justify-center mt-24">
    @!form.login({ title: 'User Login', action: route('auth.user.login') })
    @!form.register({ title: 'User Registration', action: route('auth.user.register') })

    <div class="w-full text-center mt-8">
      <a href="{{ route('auth.admin.index') }}" class="text-blue-600">
        Admin? Login here instead.
      </a>
    </div>
  </main>

@endsection
{{-- resources/views/auth/admin.edge --}}

@layout('layouts/main')

@section('content')

  <main class="flex flex-wrap justify-center mt-24">
    @!form.login({ title: 'Admin Login', action: route('auth.admin.login') })
    @!form.register({ title: 'Admin Registration (for simplicity)', action: route('auth.admin.register') })

    <div class="w-full text-center mt-8">
      <a href="{{ route('auth.user.index') }}" class="text-blue-600">
        User? Login or register here instead.
      </a>
    </div>
  </main>

@endsection

Wrapping Up

If you’ve followed all of the above, you should now have a working system allowing you to register, logout, and login using either the User model or Admin model within your AdonisJS application. Test it out, and alter what we have above to fit your needs!

Join The Discussion! (0 Comments)

Please sign in or sign up for free to join in on the dicussion.

robot comment bubble

Be the first to Comment!