🗝️

4桁や6桁のコード認証できるGem one_time_password を公開しました

2022/05/09に公開

zennでは初めまして。yosiです。

普段はGatsbyで作ったブログ(https://codelabo.com)に記事を書いているのですが、最近zennを始めてあまりに体験がいいのでこちらに記事を書いてみてます。

さっそく本題ですが、会員登録するときやログインするときにメールやSMSで4桁(もしくは6桁)の数字が送られてきて、それを入力することで本人確認が完了する機能ってありますよね。

大手サービスでもよく見る機能なのに、いざ作ろうとすると良さそうなGemが見つからなかったので作ってみました。
よければ使ってください。

仕事終わった後や休日にコツコツ2~3週間くらいで作りました。

リポジトリ

https://github.com/yosipy/one_time_password

Ruby gems

https://rubygems.org/gems/one_time_password

one_time_password Gemを使うとできること

一例として以下のようなことができます。
もちろんもっとできることは多いです。

  • 新規会員登録時などで認証コードを入力するタイプのメールアドレス確認機能開発
  • ログイン時などに認証コードを入力するタイプの2要素認証(2FA)開発

one_time_password Gemの特徴

  • 少ないソースコードでコード認証機能を開発できます。
  • 認証コード、client_tokenを発行する機能。正しいかどうか認証する機能を提供します。
  • 認証コードの有効期限を設定できます。
  • 1つの認証コードに対してユーザーが入力できる回数を指定できます。
  • 一定時間内に認証コードを指定回数間違えると、該当時間を過ぎるまで認証コードを再発行できなくします(rack-attackなどを用いてミドルウェア層での総攻撃対策と併用することを推奨)。
  • SecureRandom.random_numberを用いて認証コードを生成するので、攻撃者に予測されにくいです。
  • 認証コードを入力する前に毎回異なるclient_tokenを生成します。
  • has_secure_passwordを用いることで認証コードを復元不可能な値としてDBに保存しているので、認証コードが流出する危険性を下げられます。

そもそも認証コードを使うメリット、デメリットは?

メリット

  • モバイルアプリでの体験向上
    • 新規会員登録時にメールでトークンを含むURLを送るタイプだとURLをクリックしてもデフォルトブラウザが開いてそちらでログインするので、モバイルアプリで手動で再ログインしてもらう必要がある(知らないだけでいい方法があるかもしれません)。
  • 認証したい端末とメールが送られてくる端末が別でも問題ない
    • PCで認証したいとします。ですがメールはスマホに送られてくるかもしれません。そんなケースでは認証コードを使ったほうが体験がいいと思います。
  • 画面遷移が自然
    • 基本的に認証コードはユーザーが操作していたのと同じブラウザで入力されます。セッションやブラウザのローカルストレージに入れていた値を引き続き使うことができます。また、「URLを記載したメールを送信しました」という文字が認証完了後も画面にずっと出続けることはありません。

デメリット

  • セキュリティレベルは下がる
    • 多くのサービスで使われてるので、実用上問題はないと思います。しかし、URLに含めることのできる長いトークンと比べると4桁や6桁の認証コードは組み合わせのパターンが少ないです。入力画面ではrack-attackなどを用いて総当たり攻撃されないように、より注意する必要があります。
  • 実装コストが高い
    • 今回公開したGemは簡単に使えるようにしたつもりです。しかし、それでも機能上必要なソースコードは多少増えます。認証コードを間違えたときのテストも書く必要があります。

メリット、デメリットそれぞれもっとあると思いますが、ぱっと思いついたのはこんな感じです。

開発の経緯

昨年に業務で会員登録時のメール認証の機能改善を行い、「メールに付与された認証トークンが含まれたURLへアクセスするとメール認証が完了する機能」から「メールに付与された6桁の認証コードを画面に入力することでメール認証が完了する機能」へ変更しました。

その時は工数的に、新たに認証用のテーブルを作ったりして認証機能を1から作るのではなく、関わっているサービスの別の場所に導入されていたGemや6桁のコード認証機能を使用することで開発しました。

しかし、すでに認証フローがある程度複雑化していることもありましたし、認証コードを間違えたときの処理なども実装しているとソースコードをより複雑化させてしまいました。
もっとスマートに実装できたのではないかと思うと、喉に刺さった大骨のようにずっと気がかりでした。

そういった経緯のリベンジとして、また個人開発するアプリケーションにもコード認証を導入したいと思っていたことのでOSSのGemとして開発しました。

(なので当然ですが、今回OSSとして開発したものと業務で作ったものは全く別のロジックです。)

使い方

基本的な使い方はReadmeに書いてます。

今回は簡単な説明に留めますが、需要があればDevise token authなどを用いた実用的なチュートリアル記事を書こうと思います。

記事公開時点でのバージョンは0.2.1です。

インストール

Gemfileに

gem "one_time_password"

と追記した後に

bundle install

を行います。

もしくは以下のコマンドでインストールできます。

gem install one_time_password

必要なファイルの生成

以下のコマンドを実行します。

bundle exec rails g one_time_password:install

これによって以下の3つのファイルが生成されます。

  • Initializerファイル: config/initializers/one_time_password.rb
  • Migrationファイル: db/migrate/xxxxxxxxxxxxxx_create_one_time_authentication.rb
  • Modelファイル: app/models/one_time_authentication.rb

マイグレーション

Migrationファイルが生成されたので、マイグレーションを行ってください。

bundle exec rails db:migrate

設定を上書きする

config/initializers/one_time_password.rbFUNCTION_NAMESCONTEXTSを書き換えてください。

新規会員登録の場合はこんな感じになります。

module OneTimePassword
  FUNCTION_NAMES = {
    sign_up: 0
  }

  CONTEXTS = [
    {
      function_name: :sign_up,
      expires_in: 30.minutes,
      max_authenticate_password_count: 3,
      password_length: 6,
      password_failed_limit: 10,
      password_failed_period: 3.hour
    },
  ]
end

上の設定だと、以下のように動作します。

  • 1つの認証コードの期限は30分
  • 1つの認証コードに対してユーザーの入力回数は3回
  • 認証コードは6桁
  • 3時間以内に10回以上間違えた認証コードを入力しているとき、認証コードを発行しない

コード認証完了時に会員登録とログインを行うソースコードを書く

ここまで来たらあとはusers_controller.rbなどにソースコードを書いていきます。

今回は例として会員登録機能を作ります。

全体のフローは以下の通りです。

認証コードを発行してメールで送信する

メールアドレスをパラメータとして受け取ります。

  def create_one_time_auth
    context = OneTimeAuthentication.find_context(:sign_up)
    one_time_authentication = OneTimeAuthentication.create_one_time_authentication(
      context,
      params[:email]
    )
    if one_time_authentication.present?
      # success
      # Send one_time_password to user with email or sms.
      # And returns client_token to the client.
      OneTimePasswordMailer.send_one_time_password(params[:email].downcase, one_time_authentication.password).deliver_now
      render :json => {
        client_token: one_time_authentication.client_token
      }, status: 200
    else
      # error
      # The maximum number of passwords that can be generated in a given time has been reached
      render :json => {}, status: 401
    end
  end

find_context(:sign_up)でinitializerに記載したsign_upcontextを取得します。

取得したcontextとユーザーが入力したメールアドレスからcreate_one_time_authenticationでクライエントトークンと認証コードを発行します。
認証コードはメールで送信し、クライエントトークンはリクエスト元へ返します。

クライアントからコードを受け取ってコード認証を行う

メールアドレス、ログインパスワード、クライエントトークン、認証コード(そのほかユーザーネームなど会員登録に必要なもの)をパラメータとして受け取ります。

  def create
    context = OneTimeAuthentication.find_context(:sign_up)
    one_time_authentication = OneTimeAuthentication.find_one_time_authentication(
      context,
      params[:email]
    )
    new_client_token = one_time_authentication.authenticate_one_time_client_token!(params[:client_token])
    if new_client_token
      if one_time_authentication.authenticate_one_time_password!(params[:one_time_password])
        # success
        sign_up(one_time_authentication.user_key, params[:user_password])  # example helper method
        return render :json => {}, status: 200
      else
        if one_time_authentication.under_valid_failed_count?
          # Please reauthentication.
          return render :json => {
            client_token: new_client_token
          }, status: 401
        else
          # error
          # Over valid failed_count
          return render :json => {}, status: 401
        end
      end
    end

    # error
    return render :json => {}, status: 401
  end

find_one_time_authenticationでメールに送った認証コード情報が入っているレコードを取り出します。

次にauthenticate_one_time_client_token!でクライエントトークンが正しいかチェックしてます。
正しければ新しいクライエントトークンを生成して返し、間違っていたらnilを返します。
このクライエントトークンが一致しなければ、認証コードを発行したユーザーではないということでエラーにします。ここでは401を返してますが、お任せで。

authenticate_one_time_password!は受け取った認証コードが正しいかを判定します。

認証コードが正しければ会員登録やログインなどの処理を行います。

認証コードが間違っていたらunder_valid_failed_count?で認証した回数が指定した回数を超えてないかチェックします。

指定した回数より少なければ再入力するためのクライエントトークンを返します。

指定した回数より多ければこの認証コードは使えないのでエラーにします。
ユーザーがやり直したい場合は、再度新しい認証コードを発行するようにやり直してもらいます。

ちなみに解説に使ったソースコードはリポジトリに含んでます

まとめ

初めてGemを作ってみたのですが、なかなか面白かったです。
インストールコマンド作ってみたり、Railsが丸々dummyフォルダに入っていてテストが書きやすかったり、開発体験はかなり良かったです。

自分でも使おうと思ってるので、しばらくは開発を続けていく予定です。

指摘やコメントお待ちしております!

触ってみていただいたり、Starやいいねしてもらえると宝物箱に入れて大事にします。

リポジトリ

https://github.com/yosipy/one_time_password

Ruby gems

https://rubygems.org/gems/one_time_password

Discussion