Add Authentication to Your AdonisJS Project

wrrnwng

Warren Wong

Posted on April 7, 2021

Add Authentication to Your AdonisJS Project

If you haven't created an AdonisJS 5.0 app yet, you can check out my previous post or follow the docs here.

Coming from the JavaScript/NodeJS world, I'm pretty used to having to pick off the npm menu for all the libraries I need, which then becomes a curation problem. With AdonisJS, there is usually a library already there for you to use to add in the common functionality you'd expect from any MVC framework.

First, we should install all the dependencies then run the invoke script for each of these libraries:

# npm
npm i @adonisjs/auth@alpha @adonisjs/session@alpha

# yarn
yarn add @adonisjs/auth@alpha @adonisjs/session@alpha
Enter fullscreen mode Exit fullscreen mode
node ace invoke @adonisjs/auth
node ace invoke @adonisjs/session
Enter fullscreen mode Exit fullscreen mode

Select the appropriate options for your project. You can see my choices below.

❯ 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/1587988332388_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
Enter fullscreen mode Exit fullscreen mode

I don't currently have a User model for this project, so running the invoke script will create it for me.

If you haven't already done so, install @adonisjs/lucid as well:

# npm
npm i @adonisjs/lucid@alpha

yarn add @adonisjs/lucid@alpha
Enter fullscreen mode Exit fullscreen mode

Run the invoke script like you did for the other libraries:

node ace invoke @adonisjs/lucid
Enter fullscreen mode Exit fullscreen mode

You will be prompted to select which database to use. I'll use PostgreSQL for this example, but feel free to use any you'd like. There shouldn't be any differences for this example.

At this point, you'll have to update your .env file to reflect your setup:

PORT=3333
HOST=0.0.0.0
NODE_ENV=development
APP_KEY=...
SESSION_DRIVER=cookie
CACHE_VIEWS=false
DB_CONNECTION=pg
PG_HOST=localhost
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=postgres
PG_DB_NAME=example_app_dev
Enter fullscreen mode Exit fullscreen mode

On my local machine, my PG_USER and PG_PASSWORD are the same: "postgres". Make sure to update the PG_DB_NAME as well, since the invoke script defaults that variable to "lucid". Here I use "example_app_dev".

Now if you haven't already, create a database that matches the PG_DB_NAME variable you have in your .env file. You can do that with the command line or with something like pgAdmin 4:

psql -U postgres
Enter fullscreen mode Exit fullscreen mode
CREATE DATABASE example_app_dev;
\q
Enter fullscreen mode Exit fullscreen mode

Now run the migration created earlier:

node ace migration:run
Enter fullscreen mode Exit fullscreen mode

If you navigate to the newly created User model, you can what was created for us:

// 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 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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you've used any Active Record type ORM before, everything should look familiar. There's a couple things I just wanted to point out though. The password column has an option of {serializeAs: null}, which makes it simple to deliver the User model to JSON using the serialize method on the model. You can alias that field in the JSON with serializeAs, but if you set it to null, that field won't show up, which is what we want with the password field.

Another thing I'd like to point out is the beforeSave decorator. This hook is invoked before insert and update queries, which is an obvious place to hash a password. Another interesting thing is the $dirty property on the model. Basically, we only want to hash a new or updated password. If that particular property hasn't changed, we should do nothing.

Now comes to something that can be a gotcha with the documentation. We are calling Hash from the @ioc:Adonis/Core/Hash module. It's nice that this is already a dependency, but what might confuse you is that you'll also have to select your own hashing library. If you run the migration at this point, nothing will be amiss until you try to actually hash a password. So let's avoid that and install the argon2 dependency for this module:

# npm
npm install phc-argon2

# yarn
yarn add phc-argon2
Enter fullscreen mode Exit fullscreen mode

To add to the confusion, the phc-argon2 library itself is a fork of @phc/argon2, which is no longer maintained. You would have had to find the release notes to figure this out.

At this point you can follow the official docs because the out of the box experience is already pretty good. But if you want to keep following along for a more customized approach, please do.

If you open up the app/Middleware/Auth.ts file, you'll see the user will be redirected to the /login route if unauthenticated. I'm pretty partial to using the words "signin", "signout", and "signup" instead of "login", "logout", and "register", so that's what I'm going to do.

// app/Middleware/Auth.ts

export default class AuthMiddleware {
  /**
   * The URL to redirect to when request is Unauthorized
   */
  protected redirectTo = "/signin";
  ...
Enter fullscreen mode Exit fullscreen mode

Now let's open up the start/routes.ts file and add our new routes:

// start/routes.ts

import Route from "@ioc:Adonis/Core/Route";

Route.on("/signin").render("signin");
Route.post("/signin", "AuthController.signin");
Route.on("/signup").render("signup");
Route.post("/signup", "AuthController.signup");
Route.post("/signout", "AuthController.signout");

Route.on("/").render("welcome");
Enter fullscreen mode Exit fullscreen mode

As you can see, we'll need to create some views and a controller:

node ace make:view signin
node ace make:view signup
node ace make:controller Auth
Enter fullscreen mode Exit fullscreen mode

If you already have TailwindCSS installed in your project, great! We'll be using Tailwind to design out signin and signup views. If not, let's bring it in to our project from the CDN. If you don't already have a layout edge template, create one now:

node ace make:view layouts/default
Enter fullscreen mode Exit fullscreen mode

Now open up default.edge and add in our default HTML with our TailwindCSS dependency from CDN:

<!-- resources/views/layouts/default.edge -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
  <title>Example App</title>
</head>

<body>
  @!section('content')
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Now let's open up resources/views/signup.edge. Since I anticipate the signup and signin views will be rather similar, we can just create the signup view then copy it to signin.edge and remove the password confirmation section. We can also create a partial or component, but seeing as we're only using this particular form twice, I don't feel too bad about the duplication. There's going to be enough of a difference between the two templates that doesn't seem to make the work of generalizing the template worth it. You can, of course, do this on your own.

<!-- resources/views/signup.edge -->

@layout('layouts/default')

@section('content')
<div class="min-h-screen flex flex-col justify-center bg-gray-200 p-8">
  <div class="mx-auto bg-white px-8 py-6 shadow rounded-lg">
    <form action="{{ route('AuthController.signup') }}" method="post" class="space-y-8">
      <div>
        <label for="email" class="block text-gray-600">Email</label>
        <input type="text" name="email" value="{{ flashMessages.get('email') || '' }}"
          class="w-full px-3 py-2 border border-gray-300 rounded" />
        <p>{{ flashMessages.get('errors.email') || '' }}</p>
      </div>

      <div>
        <label for="password" class="block text-gray-600">Password</label>
        <input type="password" name="password" class="w-full px-3 py-2 border border-gray-300 rounded" />
        <p>{{ flashMessages.get('errors.password') || '' }}</p>
      </div>

      <div>
        <label for="password_confirmation" class="block text-gray-600">Re-Enter Password</label>
        <input type="password" name="password_confirmation" class="w-full px-3 py-2 border border-gray-300 rounded" />
        <p>{{ flashMessages.get('errors.password_confirmation') || '' }}</p>
      </div>

      <div>
        <button type="submit" class="w-full flex justify-center px-3 py-2 text-white bg-blue-600 rounded">Create
          Account</button>
      </div>
    </form>
  </div>
</div>
@endsection
Enter fullscreen mode Exit fullscreen mode

Figure 1

Now let's copy this file to resources/views/signin.edge, but there's one change we need to make. We need to remove the password_confirmation field and replace it with a remember_me checkbox.

<!-- resources/views/signin.edge -->

@layout('layouts/default')

@section('content')
<div class="min-h-screen flex flex-col justify-center bg-gray-200 p-8">
  <div class="mx-auto bg-white px-8 py-6 shadow rounded-lg">
    <form action="{{ route('AuthController.signup') }}" method="post" class="space-y-8">
      <div>
        <label for="email" class="block text-gray-600">Email</label>
        <input type="text" name="email" value="{{ flashMessages.get('email') || '' }}"
          class="w-full px-3 py-2 border border-gray-300 rounded" />
        <p>{{ flashMessages.get('errors.email') || '' }}</p>
      </div>

      <div>
        <label for="password" class="block text-gray-600">Password</label>
        <input type="password" name="password" class="w-full px-3 py-2 border border-gray-300 rounded" />
        <p>{{ flashMessages.get('errors.password') || '' }}</p>
      </div>

      <div class="flex items-center">
        <input type="checkbox" name="remember_me" class="h-4 w-4 border-gray-300 rounded">
        <label for="remember_me" class="ml-2 text-sm text-gray-600">
          Remember me
        </label>
      </div>

      <div>
        <button type="submit" class="w-full flex justify-center px-3 py-2 text-white bg-blue-600 rounded">Create
          Account</button>
      </div>
    </form>
  </div>
</div>
@endsection
Enter fullscreen mode Exit fullscreen mode

Now that we have our views, let's open the AuthController and add the "post" methods signin, signup, and signout. Let's just redirect to the index route for the moment.

// app/Controllers/Http/AuthController.ts

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

export default class AuthController {
  public signup({ response }: HttpContextContract) {
    // 1. Validate request

    return response.redirect("/");
  }

  public signin({ response }: HttpContextContract) {
    return response.redirect("/");
  }

  public signout({ response }: HttpContextContract) {
    return response.redirect("/");
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, I already know something about my app. I want to validate my form data before I persist my user in the database. I can do all that from the controller, but that just doesn't seem like the right place for all that logic. For a simple app like ours (so far), there's no problem writing everything in the controller. I could place all this logic in the model by adding some extra helper methods, but that's probably not a great approach either since we want to keep our models lean. I want to put all my "account" management business logic in a single place from which I can access my User model. I'll create a file at app/Account/index.ts.

// app/Account/index.ts

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

export const validationSchema = schema.create({
  email: schema.string({ trim: true }, [
    rules.email(),
    rules.unique({ table: "users", column: "email" }),
  ]),
  password: schema.string({ trim: true }, [rules.confirmed()]),
});

export const createUser = async (email: string, password: string) => {
  const user = new User();
  user.email = email;
  user.password = password;

  return await user.save();
};
Enter fullscreen mode Exit fullscreen mode

Here we created a validationSchema to be used by the request.validate method in the AuthController. It simply checked to see if the string passed in to the "email" input is in the form of an email and that it does not already exist in the users table. The password is checked to have a matching value in the "password_confirmation" input with the rules.confirmed() rule.

If there's any errors, they will show in the paragraph element under the form inputs.

// app/Controllers/Http/AuthController.ts

...
export default class AuthController {
  public async signup({ request, response }: HttpContextContract) {
    const userDetails = await request.validate({ schema: validationSchema });
    const user = await createUser(userDetails.email, userDetails.password);
    return response.json(user.serialize());
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

You can test this out by running our local server and navigating to http://localhost:3333:

You should see the serialized JSON output if the signup was successful:

{
  "email": "me@warrenwong.org",
  "created_at": "2021-04-07T15:02:51.730+08:00",
  "updated_at": "2021-04-07T15:02:51.754+08:00",
  "id": 1
}
Enter fullscreen mode Exit fullscreen mode

Now that we can sign up, let's create a protected route that will redirect to the /signin page if unauthenticated. Let's go back to our routes and create a route at /dashboard that shows all our users.

// start/routes.ts

...
import { getUsers } from "App/Account";

Route.get("/dashboard", async ({ view }) => {
  const users = await getUsers();
  return view.render("dashboard", { users });
}).middleware("auth");
Enter fullscreen mode Exit fullscreen mode

This assumes we have an exported function in our App/Account module that returns the users. The users are then passed into the Edge template as users.

// app/Account/index.ts

...
export const getUsers = async () => await User.all();
Enter fullscreen mode Exit fullscreen mode

Create the dashboard view:

node ace make:view dashboard
Enter fullscreen mode Exit fullscreen mode

Now we need to open start/kernel.ts and register our Auth middleware. You can name this anything, but "auth" seems like a decent default for this. Just make sure that the name you pick also matches the parameter passed to the middleware method in your routes.

// start/kernel.ts

...
Server.middleware.registerNamed({
  auth: "App/Middleware/Auth",
});
Enter fullscreen mode Exit fullscreen mode

Now let's work on our dashboard before we get any further. We don't have too much data to display, but we may in the future, so let's anticipate that by displaying a nice table:

<!-- resources/views/dashboard.edge -->

@layout('layouts/default')

@section('content')
<div class="min-h-screen flex flex-col">
  <div class="m-16">
    <div class="shadow border-b border-gray-300 rounded-lg">
      <table class="min-w-full divide-y divide-gray-300">
        <thead class="bg-gray-50 text-left">
          <tr>
            <th class="px-6 py-4 text-gray-700">
              ID
            </th>
            <th class="px-6 py-4 text-gray-700">
              Email
            </th>
            <th class="px-6 py-4 text-gray-700">
              Created
            </th>
            <th class="px-6 py-4 text-gray-700">
              Updated
            </th>
          </tr>
        </thead>
        <tbody class="bg-white">
          @each(user in users)            
          <tr>
            <td class="px-6 py-4 text-gray-600">
              {{ user.id }}
            </td>
            <td class="px-6 py-4 text-gray-600">
              {{ user.email }}
            </td>
            <td class="px-6 py-4 text-gray-600">
              {{ user.createdAt }}
            </td>
            <td class="px-6 py-4 text-gray-600">
              {{ user.updatedAt }}
            </td>
          </tr>
          @endeach
        </tbody>
      </table>
    </div>
  </div>
</div>
@endsection
Enter fullscreen mode Exit fullscreen mode

Now let's update the Auth controller to redirect to the dashboard whenever we've authenticated.

export default class AuthController {
  public async signup({ auth, request, response }: HttpContextContract) {
    const userDetails = await request.validate({ schema: validationSchema });
    const user = await createUser(userDetails.email, userDetails.password);
    await auth.login(user);
    return response.redirect("/dashboard");
  }
  ...
Enter fullscreen mode Exit fullscreen mode

There are a few details left, but they are pretty straight forward so I'll leave them for you to do.

  • Our "signin" method on the Auth controller has yet to be implemented. For the most part, it's similar to the our "signup" flow. I would recommend validating the data that comes from the form, but this time you do not need to have a "password_confirmation". What you do have is a "remember_me" input field that's a boolean. You'd want to update the User model with that information and persist it in the database before redirecting the user to our dashboard.
  • We don't have anyway to "signout" yet. It would be nice if you could only "signout" after you've authenticated.
💖 💪 🙅 🚩
wrrnwng
Warren Wong

Posted on April 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related