AdonisJS Authentication in 15 Minutes

We'll be creating a new AdonisJS project and adding authentication to it within 15 minutes. You'll be able to logout, register, and login with either your username or email.

Published
Dec 12, 21
Duration
16m 7s

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

Creation & Setup

First, we'll want to create our project. I'll be naming my project adonisjs-auth. If you’d like you can view the finished repository here.

npm init adonis-ts-app@latest adonisjs-auth

# ❯ Select the project structure · web
# ❯ Enter the project name · adonisjs-auth
# ❯ Setup eslint? (y/N) · false
# ❯ Configure webpack encore for compiling frontend assets? (y/N) · false

Next, let's move into the directory.

cd adonisjs-auth

Then, we'll want to install both Lucid and Auth. We'll need Lucid for Auth because the Auth package relies on Lucid to work.

npm i @adonisjs/lucid @adonisjs/auth

Once those are installed, we'll want to configure them. This will configure types, commands, and providers, and more for these packages within our project. We'll specifically want to configure Lucid first since Auth needs Lucid to work.

node ace configure @adonisjs/lucid

# ❯ Select the database driver you want to use · pg

# CREATE: config/database.ts
# UPDATE: .env,.env.example
# UPDATE: tsconfig.json { types += "@adonisjs/lucid" }
# UPDATE: .adonisrc.json { commands += "@adonisjs/lucid/build/commands" }
# UPDATE: .adonisrc.json { providers += "@adonisjs/lucid" }

Next, we'll do the same for Auth.

node ace configure @adonisjs/auth

# ❯ Select provider for finding users · lucid
# ❯ Select which guard you need for authentication (select using space) · web
# ❯ Enter model name to be used for authentication · User
# ❯ Create migration for the users table? (y/N) · true

# CREATE: app/Models/User.ts
# CREATE: database/migrations/1639232007772_users.ts
# CREATE: contracts/auth.ts
# CREATE: config/auth.ts
# CREATE: app/Middleware/Auth.ts
# CREATE: app/Middleware/SilentAuth.ts
# UPDATE: tsconfig.json { types += "@adonisjs/auth" }
# UPDATE: .adonisrc.json { providers += "@adonisjs/auth" }
# CREATE: ace-manifest.json file

With the selections I made when configuring Auth, it's also going to stub a migration file to create the users table for me. Before we run this migration, I'm going to add a username column to it and enforce my username and email to be unique.

// database/migrations/1639232007772_users.ts

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

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

  public async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id').primary()
      table.string('username', 50).notNullable().unique() // ++
      table.string('email', 255).notNullable().unique()   // add unique
      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)
  }
}

We'll also want to add the username column to our user's model.

// app/Models/User.ts

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

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

  @column()                // ++
  public username: string  // ++

  @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 (user: User) {
    if (user.$dirty.password) {
      user.password = await Hash.make(user.password)
    }
  }
}

Lastly, be sure to configure your environment variables for your database connection. Since I'm using Postgres, below are my variables & configuration. Yours is likely going to be at least a little different.

DB_CONNECTION=pg
PG_HOST=localhost 
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=password
PG_DB_NAME=adonisjs-blog

Now that we have our migration and database connection setup, we can run our user migration.

node ace migration:run

# ❯ migrated database/migrations/1639232007772_users

Installing Hashing

As you may have noticed, our User model hashing the user's password before saving the user record. In order for this to work, we're going to need to install the hashing package we wish to use. I typically use phc-argon2, so let's go ahead and install that.

npm i phc-argon2

Auto-Populating Auth User

When it comes to populating the authenticated user record for a request, we can either have AdonisJS automatically do it for us via the SilentAuth middleware, or we can manually call await auth.check(). SilentAuth allows us to set it and forget it, so we'll go with that approach here.

So open up the kernel.ts file within the start directory. Then, under the global middleware, add the SilentAuth middleware. This was added to our project when we configured the auth package.

// start/kernel.ts

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

Login With Username or Email

Since we've added a username to our user, let's go ahead and configure it so the Auth package will allow the user to login with either the username or the email. Within the Auth Config, is an array called uids. All we need to do is add username to this array.

// config/auth.ts

const authConfig: AuthConfig = {
  guard: 'web',
  guards: {
    web: {
      driver: 'session',
      provider: {
        // ...

        uids: ['username', 'email'],

        // ...
      }
    }
  }
}

Implementing Authentication

First, let's create a controller to house our authentication logic.

node ace make:controller Auth

# CREATE: app/Controllers/Http/AuthController.ts

Let's jump into that file and wire up our show pages for registration and login.

// app/Controllers/Http/AuthController.ts

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

export default class AuthController {
  public async registerShow({ view }: HttpContextContract) {
    return view.render('auth/register')
  }

  public async loginShow({ view }: HttpContextContract) {
    return view.render('auth/login')
  }
}

Then, we'll register these controller methods to routes.

// start/routes.ts

Route.get('register', 'AuthController.registerShow').as('auth.register.show')
Route.get('login', 'AuthController.loginShow').as('auth.login.show')

Next, let's create the register and login pages.

node ace make:view auth/register

# CREATE: resources/views/auth/register.edge

node ace make:view auth/login

# CREATE: resources/views/auth/login.edge

Let’s first focus on the register page.

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

<form action="{{ route('auth.register') }}" method="POST">
  <label>
    Username
    <input type="text" name="username" value="{{ flashMessages.get('username') ?? '' }}" />
    @if (flashMessages.has('errors.username'))
      <small style="color: red;">
        {{ flashMessages.get('errors.username') }}
      </small>
    @endif
  </label>

  <label>
    Email
    <input type="email" name="email" value="{{ flashMessages.get('email') ?? '' }}" />
    @if (flashMessages.has('errors.email'))
      <small style="color: red;">
        {{ flashMessages.get('errors.email') }}
      </small>
    @endif
  </label>

  <label>
    Password
    <input type="password" name="password" />
    @if (flashMessages.has('errors.password'))
      <small style="color: red;">
        {{ flashMessages.get('errors.password') }}
      </small>
    @endif
  </label>

  <button type="submit">Register</button>
</form>

Here we have a form posting to a route with the name auth.register. We'll be creating this route shortly. The flashMessages are there in case the user submits and our validation fails. The user will be redirected back to this form.

value="{{ flashMessages.get('username') ?? '' }}"

The above will populate the field's value with the previously submitted value, so the user doesn't need to retype the whole form and can actually see what they attempted to submit with.

@if (flashMessages.has('errors.username'))
  <small style="color: red;">
    {{ flashMessages.get('errors.username') }}
  </small>
@endif

This if block will then check our flashMessages for errors for specific fields. If it finds one for the requested field we then display it onto our form to notify the user.

Next, let's do the same for our login page. Remember, users can login with either their username or email, so we'll only use one field for that called uid.

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

<form action="{{ route('auth.login') }}" method="POST">
  @if (flashMessages.has('form'))
    <div role="alert">
      {{ flashMessages.get('form') }}
    </div>
  @endif

  <label>
    Username or Email
    <input type="text" name="uid" />
  </label>

  <label>
    Password
    <input type="password" name="password" />
  </label>

  <button type="submit">Login</button>
</form>

With the login page, we don't want to explicitly tell the user whether they got the username/email or password wrong for account security reasons. So, we'll instead be vague and provide a single flash message for the form, called form.

Next, let's create the login and register methods within our auth controller. While we're here let's also go ahead and add the logout functionality.

// app/Controllers/Http/AuthController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema, rules } from '@ioc:Adonis/Core/Validator'
import User from "App/Models/User";

export default class AuthController {
  // ... show methods excluded for brevity

  public async register({ request, response, auth }: HttpContextContract) {
    // create validation schema for expected user form data
    const userSchema = schema.create({
      username: schema.string({ trim: true }, [rules.unique({ table: 'users', column: 'username', caseInsensitive: true })]),
      email: schema.string({ trim: true }, [rules.email(), rules.unique({ table: 'users', column: 'email', caseInsensitive: true })]),
      password: schema.string({}, [rules.minLength(8)])
    })

    // get validated data by validating our userSchema
    // if validation fails the request will be automatically redirected back to the form
    const data = await request.validate({ schema: userSchema })

    // create a user record with the validated data
    const user = await User.create(data)

    // login the user using the user model record
    await auth.login(user)

    // redirect to the login page
    return response.redirect('/')
  }

  public async login({ request, response, auth, session }: HttpContextContract) {
    // grab uid and password values off request body
    const { uid, password } = request.only(['uid', 'password'])

    try {
      // attempt to login
      await auth.attempt(uid, password)
    } catch (error) {
      // if login fails, return vague form message and redirect back
      session.flash('form', 'Your username, email, or password is incorrect')
      return response.redirect().back()
    }

    // otherwise, redirect to home page
    return response.redirect('/')
  }

  public async logout({ response, auth }: HttpContextContract) {
    // logout the user
    await auth.logout()

    // redirect to login page
    return response.redirect().toRoute('auth.login.show')
  }
}

For our register method, we’re first creating a validation schema for our user. This will validate that our username and email are unique, that our email is a valid email, and that our password is at least 8 characters long. Then, we validate using our user schema, which returns back the validated data. We then use that validated data to create our user’s record. Then, we use that user’s new record to log the user in.

For login, all we’re doing is grabbing the uid and password off the request body, no need to validate here the attempt call will suffice. We then provide the uid and password values into the auth.attempt call to attempt to login. If it fails, we capture that error and return back a vague flash error on the session and kick the user back to the form page.

For logout, we’re simply calling auth.logout, which will take care of everything for us.

Now that we have these methods, let's go ahead and create the routes for these methods.

// start/routes.ts

Route.get('register', 'AuthController.registerShow').as('auth.register.show')
Route.post('register', 'AuthController.register').as('auth.register') // ++
Route.get('login', 'AuthController.loginShow').as('auth.login.show')
Route.post('login', 'AuthController.login').as('auth.login')          // ++
Route.get('logout', 'AuthController.logout').as('auth.logout')        // ++

Lastly, let's add a logout button onto our welcome page and display it only if the user is authenticated.

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

{{-- ... --}}

<body>

  @if (auth.user)
    <a href="{{ route('auth.logout') }}">Logout</a>
  @endif

  <main>

    {{-- ... --}}

  </main>
</body>

Test It Out!

That should do it, all that's left to do now is to test out our authentication! So, start up your server, head to [<http://localhost:3333/register>](<http://localhost:3333/register>) and test your registration. Logout, then test your login at http://localhost:3333/login.

You can start your server by running:

npm run dev

Join The Discussion! (2 Comments)

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

  1. Anonymous (JaguarMadonna833)
    Commented 1 year ago

    the script doesn't find loginShow method and registerShow method that are called in route.ts

    0

    Please sign in or sign up for free to reply

  2. Anonymous (JaguarMadonna833)
    Commented 1 year ago

    fixed

    0

    Please sign in or sign up for free to reply

Playing Next Lesson In
seconds