一度限りの使用を想定したURL生成について考える - Node.js(Express)とRedisを用いて


One time tokenを混ぜ込んだURL生成について

はじめに

これから書く内容について私自身あまり前知識を持たない状態で、かつ自力で泥臭く実現の可能性を模索して作成したため、正しくないことが書かれている恐れがあります。
もしそのような箇所を発見しましたら、編集リクエストやコメントなどでツッコミを入れていただけたら幸いです。

ユーザに一度だけ使用させるURLを生成したい

よくあるユースケースとして、ユーザ登録の場面などで、ユーザが登録を行うために一度だけその画面を使用してもらうためのワンタイムURLを生成したい場合があります。
そして登録処理を行った後は、そのURLには二度と同じURLではアクセス出来ないようにしたい。

時折見られるのはURLの末尾にTokenを付与して一度限りのURLを発行し、そのURLを登録を行おうとしているユーザ宛にメールで伝え、そこから登録処理を完了させてもらうようなパターンです。
(私もそのようなフローを用意しているWebサービスをいくつか使ってきましたが、今パッと名前が出てこない...)

Node.js(Express) with Redisで実現させる

Node.jsであればguyht/notp(Github)のようなライブラリを使えば実現できそうですが(まだ試してはいない)、一度ライブラリは使わないで実現できないか試してみたものをGithubにあげました(shinshin86/onetime-token-url-sample)

expressのapplication generatorを使用し、プロジェクトを作成し、ひとまず意図した動きができた状態でコミットしていますので、まだバグがあるかもしれません。
が、ひとまずやりたいことは実現できたので、その処理の中身について書いていきます。

サンプルアプリの中身

基本的には下記の2つのURLを使用します。

http://localhost:3000/token

Tokenを生成するためのURL。
アクセスした時点でワンタイムトークンが生成される。

http://localhost:3000/users

有効な状態のTokenを用いなければ、アクセスできないURL。
実際にアクセスする際は下記のようにURLの末尾にTokenを付与する必要がある。

http://localhost:3000/users?token=aaaaaaaaaaaaaaaa

このTokenは一度限りのもので、今回のサンプルアプリの場合、一度有効なTokenを用いてアクセスが完了すると、そのタイミングでTokenは使用済みとなり、その場で再度リロードすると、既に有効でないTokenを用いたアクセスとして認識されるため、それ以降は認証エラーとなります。
(あくまで今回はサンプルのための動作となっていますが、実際は例えばユーザ登録の場面などではユーザの登録が完了したタイミングなどでURLを使用済みと更新させることになるかと思います)

Tokenの生成

まずhttp://localhost:3000/tokenにアクセスした際、一度きりの使用を想定したURLを生成します。

ここではmiddlewareとしてToken生成の処理を実装しています。

下記のコード内でワンタイムトークンを生成し、redisにセットします。
もし既に同じtokenがredisに存在している場合は、再度生成処理を実行し、被らないTokenが生成されるまで生成をし続けるようにしています。
ここのソースについては実は元ネタがありStack Overflowで見かけたソースを参考にしていたと思うのですが、そのページのURLを失念してしまい、かつ、同じページが今現在見つけられないていないため、参考にしたURLを貼れていません。

const crypto = require('crypto');
const client = require('../utils/redisConnect')

function generateToken({ stringBase = 'base64', byteLength = 48 } = {}) {
  return new Promise((resolve, reject) => {
    crypto.randomBytes(byteLength, (err, buffer) => {
      if (err) {
        reject(err);
      } else {
        resolve(buffer.toString(stringBase).replace(/\+/g, ''));
      }
    });
  });
}

function checkToken(token) {
  return new Promise((resolve, reject) => {
    client.get(token, (err, res) => {
      if (err) {
        reject(err)
      }

      if(!res) {
        return resolve(false)
      } else {
        console.log('Already exists one time token!!')
        return resolve(true)
      }
    });
  })
}

async function handler(req, res, next) {
  do {
    res.onetime = await generateToken();
    console.log(`token : ${res.onetime}`)
  } while (await checkToken(res.onetime))

  client.set(res.onetime, 'onetime-secret')
  next()
}

module.exports = handler

ここで生成されたTokenを用いて、一度だけの使用を想定したURLに遷移させます。

Tokenの検証・認証

Tokenを用いてhttp://localhost:3000/users?token=aaaaaaaaaaaaaaaaという形でアクセスした際に、下記のようなmiddlewareを経由します。
ここで、すでにredis上に有効なTokenが存在していることを確認し、存在していればuesrsの画面を表示させるようにしています。
(実際にはusersという単語とは全く関係ない画面なので、いくら書きなぐったサンプルといえど後でちゃんとそこら辺は整えるべきかもしれません)

redis上で有効なTokenであることを確認した後、redis上から対象の値を削除します。
このmiddlewareを通過した時点で、使用したTokenは有効なTokenではなくなるため、次のアクセスからは認証エラーとなります。
(認証後の画面でリロードすると、認証エラーとなる)

実際の画面はこんな感じです↓

Tokenの削除タイミングについて実際の処理では、適正な位置に設けることで、意図した動作を定めることができるのかなと思います。

const client = require('../utils/redisConnect');

function auth (token) {
  return new Promise((resolve, reject) => {
    client.get(token, (err, res) => {
      if (err) {
        reject(err)
      }

      if(!res) {
        return resolve(false)
      } else {
        client.del(token)
        return resolve(true)
      }
    });
  });
}

async function tokenAuth(req, res, next) {
  console.log(`Authentication Token : ${req.query.token}`)
  const check = await auth(req.query.token)
  if(!check)
    res.render('tokenError',{ title: '認証エラー',
                              message: '有効なTokenではありません'})

  next()
}

module.exports = tokenAuth

まとめ

荒削りのサンプルではありますが、これはこれで一度限りのURLとしては機能できるのではないか?と思い、サンプルを元にQiitaにポストしました。
いやいや、これでは意図した動作にはならないよ!というツッコミ、
もっと、スマートに書けるよ!というマサカリ、
その他、目についた粗などありましたら、ご意見・プルリクいただけたらと思います。

追記: RedisではなくDBで管理してしまうほうが現実的かも

このポストを書いて、およそ1年半が経過しました。
ここに書いたような 特定の処理が完了したらアクセスができなくなるようなURL を実現する場合、実運用では redis ではなく 普通にDB上で管理するパターンのほうが現実的かもしれません。
(テーブルにtokenを管理するカラム用意し、そこで管理するようなイメージ)

勿論どのようなシーンで一度限りのURLというものを使うかにもよりますが、DB上で管理することでそのトークン自体の管理はしやすそうです。