Playing Next Lesson In
seconds

Let's Learn AdonisJS 7 #2.6

Form Basics, Method Spoofing, & CSRF

In This Lesson

Learn form handling in AdonisJS with method spoofing and CSRF protection. Build secure HTML forms with POST, PUT, and DELETE methods.

Created by
@tomgobich
Published

Next, let's work on allowing our users to create a new challenge by adding a form.

Making A Create Page

First, we'll need a page, and if you'll recall back to our resourceful naming, convention states this should be /challenges/create.

node ace make:view pages/challenges/create
# DONE:    create resources/views/pages/challenges/create.edge
Copied!
// ...

router.get('/challenges', [controllers.Challenges, 'index'])
router.get('/challenges/:id', [controllers.Challenges, 'show'])
router.get('/challenges/create', [controllers.Challenges, 'create'])

// ...
Copied!
  • start
  • routes.ts

Within the create method of our challenges controller, we'll render our new create page.

import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Display form to create a new record
   */
  async create({ view }: HttpContext) {
    return view.render('pages/challenges/create')
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Let's add a link for ourselves on our challenges page to point to this form. While we're here, we can clean this up a little as well.

@layout()

  <div>
    <div class="hero">
      <h1>Challenges</h1>
  
      @include('partials/challenge/available_points')
  
      <a href="/challenges/create" class="button">Create a new challenge</a>
    </div>

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
  </div>

@end
Copied!

Basic Form Submissions

Okay, great! Now onto our create page. Here we'll want a form with the fields our challenge requires.

  • text for the name of the challenge

  • points for the points our users get for completing the challenge

@layout()

  <div class="form-container">
    <div>
      <h1> Create Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    <div>
      <form action="/challenges" method="POST">
        <div>
          <label>
            Text
            <input type="text" name="text" />
          </label>
        </div>

        <div>
          <label>
            Points
            <input type="number" name="points" />
          </label>
        </div>

        <div>
          <button type="submit" class="button"> Create Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • create.edge

Again, keeping with our resourceful naming, to store new records, we should send a POST request to /challenges. So, let's create that route next.

// ...

router.get('/challenges', [controllers.Challenges, 'index'])
router.get('/challenges/:id', [controllers.Challenges, 'show'])
router.get('/challenges/create', [controllers.Challenges, 'create'])
router.post('/challenges', [controllers.Challenges, 'store'])

// ...
Copied!
  • start
  • routes.ts

Next, for our controller, when we submit our form, the data within it will be sent up within our request's body. There are numerous ways we can get body data from our request. With request.all() it will simply return everything. Let's take a peek at what we're getting first.

import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request }: HttpContext) {
    const data = request.all()
    console.log({ data })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Cross-Site Request Forgery

If we stop, fill out our form, and hit submit. You'll notice our browser refreshes and the form clears out. Let's check our server's log to see what we got!

WARN (21151): Invalid or expired CSRF token
request_id: "fac1d913-f3a0-40d8-8da0-89996276225b"
x-request-id: "fac1d913-f3a0-40d8-8da0-89996276225b"
Copied!

What you'll find is a warning that the server recieved an "invalid or expired CSRF token," what's this about? CSRF stands for Cross-Site Request Forgery, and per OWASP:

"Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing"

To protect against this, our server generates a token with every request. When we submit POST, PUT, PATCH, or DELETE requests from our application, it will expect that token to be sent along with that request as verification that the request originated from our site, as a way to protect against these malicious attacks. This token is referred to as the CSRF Token, and AdonisJS provides a utility method, csrfField() we can call to plop a CSRF token field into our forms.

@layout()

  <div class="form-container">
    <div>
      <h1> Create Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    <div>
      <form action="/challenges" method="POST">
        {{ csrfField() }}

        <div>
          <label>
            Text
            <input type="text" name="text" />
          </label>
        </div>

        <div>
          <label>
            Points
            <input type="number" name="points" />
          </label>
        </div>

        <div>
          <button type="submit" class="button"> Create Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • create.edge

Perfect! Let's try our form one more time. This time, you'll notice we get a blank page after we submit. This is because we aren't sending anything back as a response; we'll change that in a moment. First, though, let's check our log.

{
  data: {
    _csrf: '6ysucdLo-pdCD18tWTyeS8qRubQarXYs13BQ',
    text: 'Test',
    points: '10'
  }
}
Copied!

Awesome, this time we got our body data, and we can even see our CSRF token coming through to boot.

Picking Body Data

Now, it is never a good idea to take in any data the user sends. Always be defensive when it comes to the data your application accepts from users. So, instead of using request.all() we can switch this to only accept the properties we expect. For this, again, we have a couple of options.

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request }: HttpContext) {
    const data = request.only(['text', 'points'])
    const text = request.input('text')
    const points = request.input('points')
    console.log({ data, text, points })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

I want to note, though this is more defensive than using request.all() even these aren't ideal. We'll always want to validate user data. We'll cover that in the next lesson, so don't brush your hands off thinking you're done here.

import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
    const data = request.only(['text', 'points'])
    
    challenges.push({ id: challenges.length + 1, ...data })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Typically, we would take in data and store it within a database for long-term storage. We aren't there yet, so we'll work with what we have at the moment, which is our in-memory array. Note, since it's in-memory, anything we add or remove to it will be undone when we restart our server, as that destroys the memory. So, let's pick our text and points values off our request and push them as a new challenge into our challenges array. We'll also assign it an incremented ID.

Handling Form Responses

So that we aren't met with a blank page again, we can forward our user back to the challenges page so we can see our updated list.

import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
    const data = request.only(['text', 'points'])
    
    challenges.push({ id: challenges.length + 1, ...data })

    return response.redirect('/challenges')
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Responses are how we send instructions and data from our server back to the user. On our response, in our HttpContext is a redirect() method. This will alter our response to a 302, redirect status code with the Location header set to the path we've specified. The browser will take these instructions and use them to redirect the user to the challenges page.

Now, if we submit our form, not only will we be redirected, but we'll also see our new challenge added to our list!

Editing Data with a Form

Next, let's add the ability to edit a challenge by first getting our edit page created.

node ace make:view pages/challenges/edit
# DONE:    create resources/views/pages/challenges/edit.edge
Copied!

Then, we'll add two routes:

  • GET: /challenges/:id/edit to render the edit form

  • PUT: /challenges/:id to handle the updating

// ...

router.get('/challenges', [controllers.Challenges, 'index'])
router.get('/challenges/:id', [controllers.Challenges, 'show'])
router.get('/challenges/create', [controllers.Challenges, 'create'])
router.post('/challenges', [controllers.Challenges, 'store'])
router.get('/challenges/:id/edit', [controllers.Challenges, 'edit'])
router.put('/challenges/:id', [controllers.Challenges, 'update'])

// ...
Copied!
  • start
  • routes.ts

Next, we'll render our edit form within the edit method of our controller and update the challenge with the submitted data in our update method.

import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Edit individual record
   */
  async edit({ params, view }: HttpContext) {
    // find the challenge being edited by its id
    const challenge = challenges.find((row) => row.id === params.id)

    // pass the challenge to the view
    return view.render('pages/challenges/edit', { challenge })
  }

  /**
   * Handle form submission for the edit action
   */
  async update({ params, request, response }: HttpContext) {
    const data = request.only(['text', 'points'])

    // find the challenge being updated by its id, and update its data
    const challengeIndex = challenges.findIndex((row) => row.id === params.id)
    challenges[challengeIndex] = { id: params.id, ...data }

    return response.redirect('/challenges')
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

We then need to fill out our page. We can copy/paste our create page as a starting point, updating "create" to "edit." We also need to pre-populate the field's values with the values of the challenge we're editing. Finally, we need to update our form's action to point to the specific challenge ID we're looking to update.

@layout()

  <div class="form-container">
    <div>
      <h1> Edit Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    <div>
      <form action="/challenges/{{ challenge.id }}" method="POST">
        {{ csrfField() }}

        <div>
          <label>
            Text
            <input type="text" name="text" value="{{ challenge.text }}" />
          </label>
        </div>

        <div>
          <label>
            Points
            <input type="number" name="points" value="{{ challenge.points }}" />
          </label>
        </div>

        <div>
          <button type="submit" class="button"> Update Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • edit.edge

And, let's give ourselves a link to this page from our challenge's show page. I'm also going to add our "hero" class to our div as well.

@layout()

  <div class="hero">
    <h1>{{ challenge.text }}</h1>
    <p>Points: {{ challenge.points }}</p>

    <a href="/challenges/{{ challenge.id }}/edit" class="button">Edit Challenge</a>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • show.edge

HTTP Method Spoofing

If we now go to update one of our challenges, you'll notice we're met with a 404 exception.

Cannot POST:/challenges/1

The keyword of note here is "POST". We've defined this route using resourceful conventions, so its HTTP Verb is PUT and not POST. So, how do we fix this? Your first thought might be to switch the method="POST" attribute on our form to method="PUT" and that'd be fantastic, if browsers supported that. Unfortunately, they only support GET and POST as form methods. Instead, we need to use HTTP Method Spoofing. This allows us to send our request from the form as a POST but have our server understand it as a PUT, PATCH, or DELETE instead.

In AdonisJS 7, this is enabled by default, but just in case, you can find this within your config/app.ts file.

/**
 * The configuration settings used by the HTTP server
 */
export const http = defineConfig({
  // ...

  /**
   * Allow method spoofing via _method query parameter or form field.
   * Enables using PUT, PATCH, DELETE methods in HTML forms by spoofing
   * through POST requests with _method field.
   */
  allowMethodSpoofing: true,

  // ...
})
Copied!
  • config
  • app.ts

As noted here, to use it, all we need to do is include a _method=PUT query string on our form action's URL.

@layout()

  <div class="form-container">
    <div>
      <h1> Edit Challenge </h1>
      <p> Enter your challenge details below </p>
    </div>

    <div>
      <form action="/challenges/{{ challenge.id }}?_method=PUT" method="POST">
        {{ csrfField() }}

        <div>
          <label>
            Text
            <input type="text" name="text" value="{{ challenge.text }}" />
          </label>
        </div>

        <div>
          <label>
            Points
            <input type="number" name="points" value="{{ challenge.points }}" />
          </label>
        </div>

        <div>
          <button type="submit" class="button"> Update Challenge </button>
        </div>
      </form>
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • edit.edge

Now, if we attempt to update a challenge once more... voila! All works according to plan.

Join the Discussion 0 comments

Create a free account to join in on the discussion
robot comment bubble

Be the first to comment!