Madogiwa Blog

主に技術系の学習メモに使っていきます。

RailsでIDの可逆難読化する方法のメモ📝

最近、IDの可逆な方式で難読化したいけどfriendly_idみたいなmigrationが伴うものは、ちょっと避けたかったので色々と方法を考えたり人に教えてもらったりしたので、いろいろ方法とかをメモしておきます。

github.com

やりたいこと

今回は「ユーザーにコンテンツ紹介メールを送信して、そのメールに記載されたURLをクリックしたときにブックマークさせる」 という機能で考えてみます。

普通に考えると下記のような実装になるかなと思うのですが、この実装には問題点があります。

# メール文面
# このコンテンツをブックマークしませんか?
# https://example.com/page/:id/bookmark

Rails.application.routes.draw do 
  resouces :pages do
    member { get 'bookmark' }
  end
end

class User; has_many :bookmarks; end
class Page; has_many :bookmarks; end
class Bookmark
  belongs_to :user
  belongs_to :page
end

class PagesController < ApplicationController
  def bookmark
    page = Page.find(params[:id])
    if current_user.bookmarks.create(page: page)
      flash[:notice] = 'ブックマークに成功しました!'
    else
      flash[:alert] = 'ブックマークに失敗しました!'
    end
  end
end

それは、idが予測可能なためユーザーがURLを予測して独自に拡散等を行うと他ユーザーに不正に任意のページをブックマークさせるようなことが出来てしまいます。

これに対応するためにはidの難読化させて予測不能にする対応が考えられます。

ActiveRecordでUUIDを使う

まず、ActiveRecordのUUIDサポートを使うことが考えられます。 実装も特に工夫する必要もなくIDを予測不能にする必要が最初から検討できていれば良い方法かなと思います。

railsguides.jp

しかし、すでにidをintで作ってしまっていることや、Posgresqlでしか使えないのでMySqlでは使うことは出来ないといった問題点があります。

ActiveSupport::MessageEncryptorを使う

ActiveSupport::MessageEncryptorを使って可逆暗号化してIDを難読化する方法も考えられます。

api.rubyonrails.org

実装は下記のような感じになるかなと思います。

class Page
  ENCRYPT_CIPHER = "aes-256-cbc"
  ENCRYPT_SECRET = '01234567890123456789012345678901'
  has_many :bookmarks

  def self.find_with_encrypted_id(encrypted_id)
    crypt = ActiveSupport::MessageEncryptor.new(ENCRYPT_SECRET, cipher: ENCRYPT_CIPHER)
    decrypted_id = crypt.decrypt_and_verify(decrypt_and_verify)
    find(decrypted_id)
  end

  def encrypted_id
    crypt = ActiveSupport::MessageEncryptor.new(ENCRYPT_SECRET, cipher: ENCRYPT_CIPHER)
    crypt.encrypt_and_sign(id)
  end
end

class PagesController < ApplicationController
  def bookmark
    page = Page.find_with_encrypted_id(params[:id])
    if current_user.bookmarks.create(page: page)
      flash[:notice] = 'ブックマークに成功しました!'
    else
      flash[:alert] = 'ブックマークに失敗しました!'
    end
  end
end

bookmark_page_url(id: page.encrypted_id)のような形でhttps://example.com/page/NlFBTTMwOUV5UlA1QlNEN2xkY2d6eThYWWh.../bookmarkのようなURLを発行して、その難読化されたIDで検索してブックマークを作成することが出来ます。

しかしActiveSupport::MessageEncryptorで暗号化した文字列を生成すると同じ値を暗号化しても毎回違う値が生成されます。(※復号結果は変わらない) なので同一リソースを表すURLが毎回変わってしまうのはうーんという感じがしますね・・・。

scatter_swapを使う

scatter_swapというgemを使うと数字を10桁の数字に難読化することが出来ます。

github.com

このgemを使うを下記のような形でIDの難読化と復号を行う事ができます、シンプル✨

class Page
  has_many :bookmarks

  def self.find_with_encrypted_id(encrypted_id)
    decrypted_id = ScatterSwap.reverse_hash(encrypted_id).to_i
    find(decrypted_id)
  end

  def encrypted_id
    ScatterSwap.hash(id).to_i
  end
end

bookmark_page_url(id: page.encrypted_id)のような形でhttps://example.com/page/4517239960/bookmarkのようなURLを発行して、その難読化されたIDで検索してブックマークを作成することが出来ます。

しかしidが数字だということはバレてしまう + 最終更新が7年前とメンテされる気配があやしそう・・・😭

hashid-railsを使う

hashid-railsを使っても同様のことが出来ますし、かつかなりシンプルに実装出来ます(これがいいのでは?)

github.com

class Page
  include Hashid::Rails
  has_many :bookmarks
end

class PagesController < ApplicationController
  def bookmark
    page = Page.find(params[:id])
    if current_user.bookmarks.create(page: page)
      flash[:notice] = 'ブックマークに成功しました!'
    else
      flash[:alert] = 'ブックマークに失敗しました!'
    end
  end
end

bookmark_page_url(id: page.hashid)のような形でhttps://example.com/page/yLA6m0oM/bookmarkのようなURLを発行して、その難読化されたIDで検索してブックマークを作成することが出来ます。

英語なので数字よりも元のIDを予測しずらく安全性も高そうですね✨そこそこメンテナンスもされてそうです🙌

結論

今回のケースだとhashid-railsが個人的には良さそうな気がしました🙌
イケてるgemをしっておくとこういうときに便利なので色々調べておきたいなと思いました🙇‍♂️