Published on

Using Nuxt 3 to build a captcha flow

Authors

Let's build a captcha flow with Nuxt 3! We will use the new server routes to create a captcha flow that will compare the user input on the back end, hiding it from the browser to help guarantee security.

Captcha Screenshot

This example is online at captcha.fume.app, and the codebase is on github

Packages included

For this tutorial we're going to use:

  • windicss to style things and go dark
  • unstorage to store our captchas on the back end
  • Our nuxt module tailvue for toasts and buttons
  • svg-captcha to generate our captcha
  • uuid to deliver unique ids when storing captchas

TypeScript Interfaces

First we're going to declare some interfaces that we will share between the submission form and our server routes.

types/vue-shim.d.ts
export interface Captcha {
    uuid: string
    svg: string
}

export interface Submission {
    name: string
    captcha: string
    uuid: string
}

Our Captcha interface will be used on the captcha generation route, our Submission interface will be used as a combination of our form, the captcha text, and the uuid.

Storage Access

To make things easy for accessing our storage, we will create a file server/utils/storage.ts that

  • Mounts a storage instance called captcha mounting to /tmp on the server
  • Exports it as captchaStorage which can now be accessable from anywhere in our server routes
server/utils/storage.ts
import fsDriver from 'unstorage/drivers/fs'
useStorage().mount('captcha', fsDriver({ base: '/tmp' }))
export const captchaStorage = useStorage('captcha')

Captcha Generation

Next up lets create our server route that serves up our captcha, we can use the helpful command

npx
npx nuxi new api generate

This will create a file server/api/generate.ts, adding the following code will

  1. Generate a new captcha using svg-captcha
  2. Generate a new unique id using uuid
  3. Store the captcha text using the unique id
  4. Return the unique id and the SVG of our captcha
server/api/generate.ts
import svgCaptcha from 'svg-captcha'
import { v4 as uuidv4 } from 'uuid'

export default defineEventHandler(async () => {
  const captcha = svgCaptcha.create({ height: 40 })
  const uuid = uuidv4()
  await captchaStorage.setItem(uuid, captcha.text)
  return { uuid, svg: captcha.data }
})

You can view the output of this endpoint at captcha.fume.app/api/generate

Submission form

Now that we have our captcha route, lets whip up a simple submission form page.

  1. We set refs for the response of the captcha generation, and our submission object
  2. We create a function to store the generated captcha, notice we add the uuid to our submission ref
  3. Next is a function to submit our form, useFetch to make a post request to our server route (coming next)
  4. We display our captcha svg using v-html, and not show that unless its loaded
app.vue
<script lang="ts" setup>
import type { Captcha, Submission } from '@/types/vue-shim'
import { PushButton } from 'tailvue'
const { $toast } = useNuxtApp()

const captcha = ref<Captcha|undefined>(undefined)
const submission = ref<Submission>({
  name: '',
  captcha: '',
  uuid: '',
})

const getCaptcha = async () => {
  captcha.value = (await useFetch('/api/generate')).data.value as Captcha
  submission.value.uuid = captcha.value.uuid
}

interface Response {
  success: boolean
  message: string
  errors: string[]
}

const submit = async () => {
  const { data } = await useFetch('/api/submit', {
    method: 'POST',
    body: submission.value,
  }) as { data: { value: Response } }
  if (data.value?.success) {
    $toast.success(data.value?.message)
  } else {
    data.value.errors.map(e => $toast.show({ message: e, timeout: 0}))
    getCaptcha()
  }
}

onMounted(getCaptcha)
</script>

<template>
  <div class="max-w-md m-16 mx-auto flex flex-col space-y-4 border border-gray-600 bg-gray-800 p-4 rounded-lg">
    <div class="flex items-center justify-start">
      <label class="w-30" for="name">Name</label>
      <input id="name" type="text" class="w-full" v-model="submission.name" />
    </div>
    <div class="flex items-center justify-start">
      <label class="w-30" for="captcha">Captcha</label>
      <div class="w-40 mx-4 flex items-center bg-gray-300 border border-gray-600 rounded">
        <span v-if="captcha" v-html="captcha.svg" />
      </div>
      <input id="captcha" type="text" class="w-40" @keydown.enter="submit" v-model="submission.captcha" />
    </div>
    <div class="flex items-center justify-end space-x-4">
      <push-button value="Refresh" @click="getCaptcha">Refresh</push-button>
      <push-button value="Submit"  @click="submit">Submit</push-button>
    </div>
  </div>
</template>

API Route to process a submission

Now that we have our form, lets create our server route to verify and process it. We'll use the same command as before to create our server route

npx nuxi new api submit

This will create a file server/api/submit.ts, let's add the following code that will:

  1. Read the body of the request using readBody in combination with the Submission interface
  2. Check if there is an entry in our store for the unique id, if we have one we'll check if the text matches the captcha, if it does we'll return a success message, if not we'll return an error message
server/api/submit.ts
import type { Submission } from '@/types/vue-shim'

export default defineEventHandler(async (event) => {
  const body = await readBody<Submission>(event)
  const entry = await captchaStorage.getItem(body.uuid)
  if (!entry || entry !== body.captcha)
    return {
      error: true,
      errors: ['Invalid captcha.']
    }
  return {
    success: true,
    message: 'Captcha is valid.'
  }
})

That's it! From here you can do whatever you want with your data, save it to a database? Send an email?

Fume allows you to use the power of Server Side rendering which these server routes use while staying serverless with AWS Lambda, allowing you to scale your application without having to worry about scale and cost. Start your trial today!