Warren Wong
Posted on April 7, 2021
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
node ace invoke @adonisjs/auth
node ace invoke @adonisjs/session
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
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
Run the invoke script like you did for the other libraries:
node ace invoke @adonisjs/lucid
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
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
CREATE DATABASE example_app_dev;
\q
Now run the migration created earlier:
node ace migration:run
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);
}
}
}
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
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";
...
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");
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
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
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>
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
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
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("/");
}
}
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();
};
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());
}
...
}
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
}
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");
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();
Create the dashboard
view:
node ace make:view dashboard
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",
});
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
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");
}
...
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 theUser
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.
Posted on April 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.