Verifying user credentials

Verifying user credentials

In an AdonisJS application, verifying user credentials is decoupled from the authentication layer. This ensures you can continue using the auth guards without limiting the options to verify the user credentials.

By default, we provide secure APIs to find users and verify their passwords. However, you can also implement additional ways to verify a user, like sending an OTP to their phone number or using 2FA.

In this guide, we will cover the process of finding a user by a UID and verifying their password before marking them as logged in.

Basic example

You can use the User model directly to find a user and verify their password. In the following example, we find a user by email and use the hash service to verify the password hash.

import User from '#models/user'
import hash from '@adonisjs/core/services/hash'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
/**
* Find a user by email. Return error if a user does
* not exists
*/
const user = await User.findBy('email', email)
if (!user) {
return response.abort('Invalid credentials')
}
/**
* Verify the password using the hash service
*/
const isPasswordValid = await hash.verify(user.password, password)
if (!isPasswordValid) {
return response.abort('Invalid credentials')
}
/**
* Now login the user or create a token for them
*/
// ...
}
}

Issues with the above approach

The code we have written in the above example is prone to timing attacks. In the case of authentication, an attacker can observe the application response time to find whether the email or the password is incorrect in their provided credentials. We recommend you use the AuthFinder mixin to prevent timing attacks and have a better developer experience.

As per the above implementation:

  • The request will take less time if the user's email is incorrect. This is because we do not verify the password hash when we cannot find a user.

  • The request will take longer if the email exists and the password is incorrect. This is because password hashing algorithms are slow in nature.

The difference in response time is enough for an attacker to find a valid email address and try different password combinations.

Using the Auth finder mixin

To prevent the timing attacks, we recommend you use the AuthFinder mixin on the User model.

The Auth finder mixin adds findForAuth and verifyCredentials methods to the applied model. The verifyCredentials method offers a timing attack safe API for finding a user and verifying their password.

You can import and apply the mixin on a model as follows.

import { DateTime } from 'luxon'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: number
@column()
declare fullName: string | null
@column()
declare email: string
@column()
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
  • The withAuthFinder method accepts a callback that returns a hasher as the first argument. We use the scrypt hasher in the above example. However, you can replace it with a different hasher.

  • Next, it accepts a configuration object with the following properties.

    • uids: An array of model properties that can be used to identify a user uniquely. If you assign a user a username or phone number, you can also use them as a UID.
    • passwordColumnName: The model property name that holds the user password.
  • Finally, you can use the return value of the withAuthFinder method as a mixin on the User model.

Verifying credentials

Once you have applied the Auth finder mixin, you can replace all the code from the SessionController.store method with the following code snippet.

import { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'
import hash from '@adonisjs/core/services/hash'
export default class SessionController {
async store({ request, response }: HttpContext) {
async store({ request }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
/**
* Find a user by email. Return error if a user does
* not exists
*/
const user = await User.findBy('email', email)
if (!user) {
response.abort('Invalid credentials')
}
/**
* Verify the password using the hash service
*/
await hash.verify(user.password, password)
const user = await User.verifyCredentials(email, password)
/**
* Now login the user or create a token for them
*/
}
}

Handling exceptions

In case of invalid credentials, the verifyCredentials method will throw E_INVALID_CREDENTIALS exception.

The exception is self-handled and will be converted to a response using the following content negotiation rules.

  • HTTP requests with the Accept=application/json header will receive an array of error messages. Each array element will be an object with the message property.

  • HTTP requests with the Accept=application/vnd.api+json header will receive an array of error messages formatted per the JSON API spec.

  • If you use sessions, the user will be redirected to the form and receive the errors via session flash messages.

  • All other requests will receive errors back as plain text.

However, if needed, you can handle the exception inside the global exception handler as follows.

import { errors } from '@adonisjs/auth'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_INVALID_CREDENTIALS) {
return ctx
.response
.status(error.status)
.send(error.getResponseMessage(error, ctx))
}
return super.handle(error, ctx)
}
}

Hashing user password

The AuthFinder mixin registers a beforeSave hook to automatically hash the user passwords during INSERT and UPDATE calls. Therefore, you do not have to manually perform password hashing in your models.