Prerequisites
Before we begin, you’re going to want to have a Git Repository created and ready to go so that you can grab its URL for a few of the earlier steps.
What We’ll Be Building
In this lesson, we’ll be building a simple Honeypot package for AdonisJS. Honeypots are an easy way to prevent bots from submitting forms by injecting realistic fields into the form that only those bots can see. We’ll then have a middleware on our server that checks to see if those fields were filled out. If they were, we know a bot submitted the form and we can safely reject the submission.
You can find all the code for this package on GitHub. This package is also on NPM.
Getting Started
The first step here is to create a new folder on our system where we’ll be building the package. I’ll be naming mine adonisjs-honeypot
. So, in your terminal let’s run the following to create the folder, then change into its directory.
mkdir adonisjs-honeypot cd adonisjs-honeypot
Copied!
Initialize NPM
Next, let’s go ahead and initialize NPM within our directory so that we can install the needed dependencies.
npm init # ❯ package name: @adocasts.com/adonisjs-honeypot # ❯ version: 1.0.0 # ❯ description: # ❯ entry point: build/providers/HoneypotProvider.js # ❯ test command: # ❯ git repository: https://github.com/adocasts/adonisjs-honeypot.git # ❯ keywords: # ❯ author: adocasts.com,tomgobich # ❯ license: MIT
Copied!
Now that we have a package.json
file in our repository to track our dependencies, we’re good to move onward.
MRM & The AdonisJS MRM Preset
If you’re not familiar with it, there’s a command-line tool called MRM which aids in keeping configuration files in sync. This is especially useful for open-source communities so that they can make as much of their community's code the same.
AdonisJS has its own MRM Preset, which we’ll be using to scaffold our package’s configuration. This preset provides a list of tasks we can execute, and it’ll alter our package’s configuration files to match that of the rest of the AdonisJS Core and community.
Installing & Configuring MRM
So, let’s go ahead and get both of these installed and set up within our package.
npm i -D mrm @adonisjs/mrm-preset
Copied!
Once those are installed, go ahead and open your package within your text editor of choice. Let’s jump into the package.json
file and add an additional script for MRM.
"scripts": { "mrm": "mrm --preset=@adonisjs/mrm-preset", "test": "echo \\"Error: no test specified\\" && exit 1" }
Copied!
With that added, we can now run any of the tasks defined within the AdonisJS MRM Preset.
Running MRM Tasks
Now that we have MRM and the AdonisJS MRM Preset setup within our project, let’s go ahead and make use of it by getting what we need for our package set up.
npm run mrm init # ❯ Enter git origin url: https://github.com/adocasts/adonisjs-honeypot.git # ❯ Select the minimum node version your package will support: 16.13.1 # ❯ Is it a package written by the AdonisJS core team: No # ❯ Automatically generate TOC for the README file: No # ❯ Select project license: MIT # ❯ Select CI services...: # ❯ Select probot applications...:
Copied!
Once you run through the questions, this command will go ahead and initialize the Git repository for you within your directory and set up your origin with Git as well.
Next, let’s go ahead and run a few more tasks.
npm run mrm gitignore # will generate a default .gitignore file npm run mrm license # will generate our MIT license file npm run mrm package # will add testing capabilities, scripts, and more
Copied!
Defining AdonisJS Dependencies & Types
Before we start writing the code for our package, we’ll need to define what packages from AdonisJS our package will need. For our Honeypot package this’ll be core, view, and optionally session.
First let’s add them as Peer Dependencies within our package.json
. We’ll use peerDependenciesMeta
to specify session is optional.
"peerDependencies": { "@adonisjs/core": "^5.1.0", "@adonisjs/session": "^6.0.0", "@adonisjs/view": "^6.0.0" }, "peerDependenciesMeta": { "@adonisjs/session": { "optional": true } },
Copied!
Next, let’s copy those peerDependencies
over to our package devDependencies
as well.
"devDependencies": { "@adonisjs/core": "^5.4.2", "@adonisjs/session": "^6.1.2", "@adonisjs/view": "^6.1.1", "...": "... other packages" },
Copied!
We’ll also want to add their types to our TypeScript config as well within our tsconfig.json
.
{ "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", "compilerOptions": { "skipLibCheck": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "types": [ "@types/node", "@adonisjs/core", "@adonisjs/session", "@adonisjs/view" ] } }
Copied!
While we’re in here go ahead and add skipLibCheck
, experimentalDecorators
, and emitDecoratorMetadata
as well. Skipping the lib check will prevent the core package from throwing errors when we try to build. Then, we’re essentially turning on decorators with the two decorator flags, which will allow us to utilize the @inject
decorator.
Adding Our Provider
We’re now ready to start writing our package’s code! So, let’s get started by creating our provider file at /providers/HoneypotProvider.ts
. Here we’re defining our package’s IoC Namespace as Adocasts/Honeypot
, which’ll return our middleware; we’ll create that in a minute. We’ll also add our global Honeypot component to Edge.
import { ApplicationContract } from '@ioc:Adonis/Core/Application' export default class HoneypotProvider { public static needsApplication = true constructor (protected app: ApplicationContract) {} public register () { // Register your own bindings this.app.container.singleton('Adocasts/Honeypot', () => { // import middleware // return middleware }) } public async boot () { // IoC container is ready // register Honeypot component to Edge } }
Copied!
Creating Our Config
Before we begin writing our middleware, let’s go ahead and get our package’s configuration defined. Now, unlike everything else with our package, we’ll actually want our configuration file moved into the user’s project so they can make changes as needed.
We can easily do this by placing it inside a templates
directory. This directory is used to copy files from our package into the user’s project. So, let’s create this folder along with a honeypot.txt
file. Note the .txt
there, we’ll need to define it as a txt file in our package. AdonisJS, when copying it to the user’s project will then convert it to TypeScript for us.
When writing your configuration file, remember to write it as though you’re in the user’s project and not within your package!
So, let’s go ahead and define this at /templates/honeypot.txt
.
/** * Config source: <https://github.com/jagr-co/adonisjs-honeypot/blob/main/templates/honeypot.txt> * * Feel free to let us know via PR, if you find something broken in this config * file. */ import { HoneypotConfig } from '@ioc:Adocasts/Honeypot' /* |-------------------------------------------------------------------------- | Honeypot |-------------------------------------------------------------------------- | | Bots run through forms filling out each visible field. This honeypot | traps those bots and prevents their request from being fulfilled. | Bots tend to check for simple hidden fields like type=hidden, classes | like "hidden", and the inline-style display: none. This plugin will | instead use CSS clamping to hide the customizable list of fields you | define below. Just be sure your fields below won't collide with any | actual form fields your application will need. */ export const honeypotConfig: HoneypotConfig = { /* |-------------------------------------------------------------------------- | Fields |-------------------------------------------------------------------------- | | List of fields that will be added to your form when using the | honeypot component. The more realistic the field name, the more | likely it is to work. | */ fields: ['ohbother', 'ohpiglet', 'ohpoo', 'firstName', 'lastName'], /* |-------------------------------------------------------------------------- | Display flash message on failure |-------------------------------------------------------------------------- | | When true, a flash message will be added to the session when the | honeypot fails due to the hidden fields being filled out. In order for | a flash message to be added, `flashMessage` and `flashKey` must have a | value. | */ flashOnFailure: true, /* |-------------------------------------------------------------------------- | Flash message displayed on failure |-------------------------------------------------------------------------- */ flashMessage: 'Our system flagged you as a bot', /* |-------------------------------------------------------------------------- | Key used for flash message on failure |-------------------------------------------------------------------------- */ flashKey: 'error', /* |-------------------------------------------------------------------------- | Redirect the user on failure |-------------------------------------------------------------------------- | | When true, the user will be redirect to your defined redirectTo path. | When false, or when redirectTo is null or empty, an error will be thrown | which will prevent the bot form landing back at your form page and | resubmitting. | */ redirectOnFailure: false, redirectTo: null }
Copied!
Creating Package Exceptions
Let’s also go ahead and create the two exceptions we’ll use within our middleware whenever issues are found. We’ll have one when the honeypot has been filled out and another for when the honeypot fields are missing.
First, let’s create a folder called src
within our package root. Within here, let’s create another folder called Exceptions
, where both our exceptions will reside.
Then, let’s create our HoneypotFailureException.ts
file:
import { Exception } from '@poppinss/utils' export class HoneypotFailureException extends Exception { public static invoke () { return new this('Honeypot Validation Failed', 403, 'E_HONEYPOT_FAILURE') } }
Copied!
We’ll import Exception
from @poppinss/utils
, which I believe comes with the AdonisJS Core. From there, define a static invoke
method that’ll call and return our exception.
That takes care of our exception for when the honeypot fails, now let’s do the same for missing fields by creating our NoHoneypotFieldsFoundException.ts
file:
import { Exception } from '@poppinss/utils' export class NoHoneypotFieldsFoundException extends Exception { public static invoke () { return new this( 'All honeypot fields were missing from your form. Did you forget to add the honeypot component?', 403, 'E_NO_HONEYPOT_FIELDS_FOUND' ) } }
Copied!
Creating Our Middleware
Next, let’s go ahead and create the middleware we’ll export out of our package’s IoC Namespace. This middleware will be in charge of checking whether any honeypot fields have been filled in; meaning a bot submitted the form. We’ll also add another check to see if all honeypot fields are missing. If they are, we’ll log a warning to the dev that they may be missing the honeypot component within their form.
Initial Middleware File
import { ApplicationContract } from '@ioc:Adonis/Core/Application' import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import { inject } from '@adonisjs/core/build/standalone' @inject(['Adonis/Core/Application']) export class HoneypotMiddleware { constructor (private app: ApplicationContract) {} public async handle ({ request, response, session, logger }: HttpContextContract, next: () => Promise<void>) { await next() } }
Copied!
Here we have our initial middleware file. For the most part it’s just like any other middleware. However, we are injecting the Adonis/Core/Application
namespace into our class. We’re then using that injection to provide the app
to our class instance. This will allow us to grab our package’s config.
Importing Our Config
We haven’t created our config file yet, we’ll do that after we get our middleware setup, but let’s go ahead and get the import ready using our app
.
import { ApplicationContract } from '@ioc:Adonis/Core/Application' import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import { inject } from '@adonisjs/core/build/standalone' @inject(['Adonis/Core/Application']) export class HoneypotMiddleware { // 👇 private config = this.app.container.resolveBinding('Adonis/Core/Config').get('honeypot.honeypotConfig') constructor (private app: ApplicationContract) {} public async handle ({ request, response, session, logger }: HttpContextContract, next: () => Promise<void>) { await next() } }
Copied!
Here we’re resolving the namespace Adonis/Core/Config
so that we can use the Config
object to get
our honeypot.honeypotConfig
. The initial honeypot
specifies the file to look up inside the config
directory. Then, honeypotConfig
specifies the exported name to grab.
Writing Our Middleware
Now that we have everything we need, let’s finish up our middleware by defining our checks. Remember, just like any middleware the handle
method is the entry point here and in order to allow the code to continue onward, we’ll need to call next()
once we’ve verified everything we need to within our middleware.
import { ApplicationContract } from '@ioc:Adonis/Core/Application' import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import { HoneypotFailureException } from '../Exceptions/HoneypotFailureException' import { NoHoneypotFieldsFoundException } from '../Exceptions/NoHoneypotFieldsFoundException' import { inject } from '@adonisjs/core/build/standalone' @inject(['Adonis/Core/Application']) export class HoneypotMiddleware { private config = this.app.container.resolveBinding('Adonis/Core/Config').get('honeypot.honeypotConfig') private states = { VALID: 1, // all good INVALID: 2, // honeypot field had a value MISSING: 3, // no honeypot fields found } private supportedMethods = ['POST', 'PUT', 'PATCH'] constructor (private app: ApplicationContract) {} private validateFields (values: { [p: string]: any }) { let state = this.states.VALID if (Object.keys(values).length === 0) { return this.states.MISSING } for (let key in values) { if (values[key]) { state = this.states.INVALID } } return state } public async handle ({ request, response, session, logger }: HttpContextContract, next: () => Promise<void>) { if (!this.supportedMethods.includes(request.method())) { logger.warn(`[adonisjs-honeypot] Provided Http Method "${request.method()}" is not supported.`) return next() } const honeyValues = request.only(this.config.fields) const state = this.validateFields(honeyValues) // no honeypot fields found if (state === this.states.MISSING) { throw NoHoneypotFieldsFoundException.invoke() } // honeypot field contained value if (state === this.states.INVALID) { if (this.config.flashOnFailure && this.config.flashMessage && this.config.flashKey) { session.flash(this.config.flashKey, this.config.flashMessage) } if (!this.config.redirectOnFailure || !this.config.redirectTo) { throw HoneypotFailureException.invoke() } return response.redirect(this.config.redirectTo) } // all good, continue await next() } }
Copied!
So, let’s walk through our handle
method here.
First, we’re checking if the Http Method is supported. If not, we’ll log a warning for the developer and then return
next()
to skip the middleware check.Then, we’re grabbing just the honeypot fields out of the request body and validating those fields to see
If we have fields passed through (missing)
If any were filled out (invalid)
Lastly, we’re checking the returned state from our validation check and handling any issues as defined by the developer within our config file.
Finishing Our Provider
Now that we have our middleware completed, we can go ahead and finish up our provider as well.
import { ApplicationContract } from '@ioc:Adonis/Core/Application' export default class HoneypotProvider { public static needsApplication = true constructor (protected app: ApplicationContract) {} public register () { // Register your own bindings this.app.container.singleton('Adocasts/Honeypot', () => { // 👇 import our middleware and return it as the main value of our namespace const { HoneypotMiddleware } = require('../src/Middleware/HoneypotMiddleware') return HoneypotMiddleware }) } public async boot () { // IoC container is ready // 👇 import view and our config & create the honeypot form fields const View = this.app.container.resolveBinding('Adonis/Core/View') const honeypotConfig = this.app.container.resolveBinding('Adonis/Core/Config').get('honeypot.honeypotConfig') const fieldTemplate = honeypotConfig.fields.map(f => `<input type="text" class="ohbother" name="${f}" />`).join('') // 👇 register our component to Edge View.registerTemplate('honeypot', { template: ` <style> .ohbother { opacity: 0; position: absolute; top: 0; left: 0; height: 0; width: 0; z-index: -1; } </style> ${fieldTemplate} `, }) } }
Copied!
First, we’re importing our middleware within our IoC Namespace singleton and returning it back out of it so that it’s the main value of our IoC Namespace.
Then, we’re using View
to register a global component called honeypot
so that it can be used anywhere with AdonisJS Edge. This is looping over all field definitions in the developer’s config and creating a text input with them.
Remember, we need these fields to be hidden from real users but visible to bots. Your first thought might be to use a type of hidden or a style tag on the field, but bots tend to watch out for those. So, we’ll add a style tag with a class to hide our fields using CSS as that’s more difficult for bots to detect.
Defining Our Package’s TypeScript Types
We’re almost done! Let’s get the TypeScript types defined for our package. For this, let’s create a folder called adonis-typings
, and within it create a honeypot.ts
file.
Within here we’ll want to declare a module on our namespace and define all the types for our namespace.
declare module '@ioc:Adocasts/Honeypot' { import { ApplicationContract } from '@ioc:Adonis/Core/Application' import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' export type HoneypotConfig = { fields: string[] flashOnFailure: boolean flashMessage: string | null flashKey: string | null redirectOnFailure: boolean redirectTo: string | null } export interface HoneypotMiddlewareContract { new (application: ApplicationContract): { handle(ctx: HttpContextContract, next: () => Promise<void>): any } } const HoneypotMiddleware: HoneypotMiddlewareContract export default HoneypotMiddleware }
Copied!
First, we’ll define and export the type for our HoneypotConfig
. This will allow us to import it within our config file to define the properties and types our config expects.
Then, we’ll export a contract interface for our middleware. Since we’re injecting our ApplicationContract
, we’re specifying that for the constructor. Then, we’re defining the expectations for our handle method.
Lastly, we’ll define the property HoneypotMiddleware
and set that as the default export. So, in total we’re describing the following potential imports for our namespace:
import HoneypotMiddleware, { HoneypotConfig } from '@ioc:Adocasts/Honeypot'
Copied!
Where HoneypotMiddleware
is the actual middleware and HoneypotConfig
is our type definition for our config.
Lastly, let’s create and index.ts
file within our adonis-typings
folder that’ll reference our honeypot.ts
typings.
/// <reference path="./honeypot.ts" />
Copied!
And yes, the ///
is intentionally included. You can think of this line as “importing” our honeypot.ts
typings.
Defining Our Package’s Instructions
We have the option to display instructions to the developer after they configure our package within their project. These are typically to give the developer a heads up on what to do next. In our case, we’ll want to inform them to register the named middleware for use on their routes.
So, in the root of our project, let’s add an instructions.md
file. You can write any valid Markdown in here.
The Adocasts package @adocasts/adonisjs-honeypot
has been successfully configured. Before you begin, please register the below named middleware inside your start/kernel.ts
file.
Server.middleware.registerNamed({ honeypot: () => import('@ioc:Adocasts/Honeypot') // 👈 })
Copied!
CopyFiles, Moving Our Instructions & Templates to Build
Second to last step, if we were to build our project now, the instructions.md
file and templates
directory would be omitted. We’ll want to install a package called copyfiles and add a command to our package.json
build command to move these files over.
So, first, let’s install the copyfiles
package.
npm i copyfiles
Copied!
Then, add in the additional script commands.
"scripts": { "...": "... omitting other scripts not changed", "compile": "npm run lint && npm run clean && tsc && npm run copy:files", "copy:files": "copyfiles \\"templates/**/*.txt\\" build && npm run copy:instructions_md", "copy:instructions_md": "copyfiles \\"instructions.md\\" build" },
Copied!
compile
We’re adding to the end&& npm run copy:files
to execute thecopy:files
command post-build.copy:files
This will move over our templates directory and it’s files to the build directory, then callcopy:instructions_md
to move that over as well.copy:instructions_md
This will move ourinstructions.md
file over to the root of the build directory
Making Our Package Configurable
The last thing to do is to make our package configurable, making it easier to install! This is where we can automatically register our providers and types, move our config into the developer’s project, and display our instructions to the developer.
To do this, we can add an “adonisjs” property to our package.json
.
"adonisjs": { "instructionsMd": "./build/instructions.md", "types": "@adocasts.com/adonisjs-honeypot", "providers": [ "@adocasts.com/adonisjs-honeypot" ], "templates": { "config": [ "honeypot.txt" ] } },
Copied!
instructionsMd
For this, we’ll want to point to the build path for our instructions file. Providing this will inform AdonisJS that we have instructions to display after our package is configured.types
Here we’ll want to provide the value that should be added to thetsconfig.json
types
array. Which, in this case, is our package’s name.providers
The same thing as types here, we want to provide what should be added to our.adonisrc.json
providers
array. Again, this is our package’s name.templates
For this, we’ll want to provide an object. The key of the object is the folder within the developer’s project the template should be added to. The value of the key should be an array of the file from our project that should be moved there. So, we’ll be movinghoneypot.txt
to ourconfig
file. Adonis will then map thatconfig
holds.ts
files and change ourhoneypot.txt
tohoneypot.ts
.
Wrapping Up
With that, our package should be ready for usage! We’ll be able to submit it to the NPM registry, and install it using @adocasts.com/adonisjs-honeypot
, and configure it within our project using node ace configure @adocasts.com/adonisjs-honeypot
.
Bloopers
A written blooper? Yep! So, you might be wondering why I’m using @adocasts.com/package-name
instead of just @adocasts/package-name
. Well… I’d love to, but I did an oopsie. I previously had an NPM user called adocasts
. Well, I thought “shoot, this should probably be an organization”. So I deleted the user and went to go create the organization. The user didn’t have any packages tied to it so I didn’t second guess anything. Well turns out NPM locks usernames once any account, even those that have been deleted, has claimed it. So, since I deleted the adocasts account, I can no longer reclaim it. Hense the @adocasts.com
username.
Also, in case you’re wondering, yes there is an option to turn a user account into an organization. I failed to see that until after I had deleted my adocasts account.
Life goes on and lessons are learned.
Join The Discussion! (2 Comments)
Please sign in or sign up for free to join in on the dicussion.
Anonymous (SpiderFelicia85)
Nice job fellows
Please sign in or sign up for free to reply
tomgobich
Thank you!! 😊
Please sign in or sign up for free to reply