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