はじめに
こんにちは、こんばんは、初めまして。
プログラミングスクールRUNTEQで学習し、現在就活フェイズに入ったmassanです。
ひょんなことからRUNTEQ内でアウトプットリレーという企画に巻き込まれてしまい、毎週月曜日に何かしら記事を書くことになってしまいました。
今週は技術記事ということなので、今回は自分がRUNTEQの卒業制作として個人開発したMusic Hourで実装した、「招待リンク生成機能」についてアウトプットさせていただこうと思います。
Music Hourとは?
「Music Hour」はラジオ番組へのお便りをイメージしたツールです。リスナーからのお便りを募集し、配信などで公開するためのお手伝いをします。
具体的には、配信者が番組と呼ばれるページを作成し、リスナーにそのページに対してお便りを投稿してもらうといった、簡単に言えば掲示板のようなものです。
投稿されたお便りは一部を除いて配信者しか見ることができず、配信者は投稿されたお便りをランダムに表示したり、既読、非表示などの状態を設定してお便りを管理したりすることができるようになります。
その他の機能としては配信者が番組ページを管理運営するために、番組の表示、非表示機能や、お便り箱の管理機能なども実装しています。
現在はプロトタイプとしてMusic Hourを運営していますが、ゆくゆくは配信者がリスナーを獲得し、維持するためのお手伝いができるような新たなプラットフォームとして提供できるようにしていきたいと考えています。
対象読者
- MVCやRailsの基本的な動作が理解できる
- 認証機能がある程度理解できる
- ユーザーの招待や各種機能へのユーザーの招待機能を実装してみたい
- Music Hourの招待リンク生成機能について知りたい
環境
- Windows11
- Ubuntu 24.04 LTS (WSL2)
- Docker
- Ruby 3.3.6
- Rails 7.2.1
前提
DBの関係する部分は以下のような感じ
なんのことはないDeviseを使って登録したユーザーがタイトルと本文、その他カラムを持った掲示板と関連付けされているという感じ
今回やりたかったことはuserがprogramを複数人で編集可能にするために、他のuserに対してprogramの編集権限を付与できるようにすることです
つまり、programの編集(管理運営)に招待することができるようにしたかったんです。
よくあるのは、自分のアプリ内でユーザーを検索 → programに対して招待したいユーザーを選択 → 招待を送信 → 招待されたユーザーはアプリ内の通知から参加
または招待の送信と通知の部分をメールに代替してもらう
みたいな感じだと思います。
しかしこの方法だと私は以下の点が気に入りませんでした。
- 複数人招待したい場合、必ず「ユーザーを検索し、追加する」といった作業が、複数回必要になる(操作がめんどくさそう)
- 「アプリの通知?どこ?」「メール来てないけど?」のようなやり取りが発生する場合がある
- 通知機能作るのがめんどくさい(ここ重要。)
というわけでDiscordなどのサービスに使用されているような「招待リンク制」にし、リンクを踏んだらprogramの管理運営に参加できるという方法をとることにしました。
招待リンクの考え方
招待リンクについて考えてみましょう。
リンクを踏めば参加確認ページに遷移する。といった感じにするのならば
ルーティング設定、コントローラーの設定を行い、そのprogram専用の参加ページを作成すればよさそうですね
しかし単純にページを作成するだけでは、リンクが予想できてしまった場合に招待したくないuserまで参加できてしまったり、リンクがバレてしまった場合に対応方法がないといったことになってしまいます。
この状況、ユーザーのパスワードリセットの時とよく似ていませんか?
- ユーザーはパスワードを忘れてしまっているためログインができない。(つまりそのユーザー専用のページにアクセスできない)
- emailを入力してもらい、そのemailに対してパスワード再設定用ページのリンクを送信
- ユーザーパスワードリセットページからパスワードを再設定
パスワードリセット機能はどのサービスでもこのような感じですよね?
パスワード再設定のリンクにはそれぞれがユニークでランダムな文字列である「トークン」が
ストロングパラメーターとして含まれており、これがパスワードのような役割を果たしています
さらにこのトークンに有効期限を設定することで、万が一リンクが外部に漏れてしまった場合にもその状態が続いてしまうということを防いでいます。
これを応用すればセキュアな招待リンクの生成が可能そうですね!
Music Hourでは初期にRails tutorialを参考に認証機能を実装していたので、Rails tutorial 9章、12章を参考にこの機能を実装していきます。
今回の要件
前置きが長くなりましたが以上を踏まえて今回実装に必要なものを箇条書きにしてみます
-
招待リンクの生成
- 掲示板を作成したユーザーが、特定の他のユーザーを招待するためのユニークな招待リンクを生成できる
-
リンクの有効期限設定
- 招待リンクには有効期限を設定できる(今回は3日)
-
リンクの再生成機能
- 既存の招待リンクを無効にし、新たにリンクを生成できる
-
招待されたユーザーの登録
- 招待リンクをクリックしたユーザーが番組制作に参加できる
-
招待状況の確認
- 番組制作に参加しているユーザー一覧の表示
実装イメージとしてはこんな感じ
招待リンクの生成
招待リンクからの参加
実装
必要なテーブルとカラムの追加
結論から言うとこんな感じになります
user_participations
テーブルを追加し
programテーブルにinbitation_digest
とsend_invitation_at
カラムを追加しました
追加されたものの説明
-
user_participationsテーブル
- programを複数のuserで管理するので当然中間テーブルが必要になります
-
programテーブル
-
inbitation_digest
- リンクに含めるトークンをハッシュ化したものを保存しておきます。このカラムに保存されているものとリンクに含まれているトークンをハッシュ化したものを比べることで正しいリンクかどうかを判断します。
-
send_invitation_at
- トークンを生成した時間を記録しておきます。この時間を用いてリンクが有効期間内かどうかを確認します。
-
inbitation_digest
今回はすでにuserとprogramの関連付けがされていたので、テーブルとカラムを追加しただけで元の関連付けを削除していません。
これは最初にそのprogramを作成したuser(host)を把握するためにそのままにしてあります。
programを管理できるuserの中でhostとそれ以外で使える機能を分けておいたほうがトラブルになりにくいと思ったのでこういう形にしました。(例えばhost以外がprogramを削除してしまうなど)
しかし、こういったものはuser_participations
中間テーブルにroleカラムを追加して管理したほうが後々便利だと思うので、今回の実装はあまりおすすめできません。
今回はすでにレコードが複数あり、修正が面倒だったので暫定的にこうなっています。
class CreateUserParticipations < ActiveRecord::Migration[7.2]
def change
create_table :user_participations do |t|
t.references :user, foreign_key: true
t.references :program, foreign_key: true
t.timestamps
end
add_index :user_participations, [ :user_id, :program_id ], unique: true
end
end
class AddInvitationToPrograms < ActiveRecord::Migration[7.2]
def change
add_column :programs, :invitation_digest, :string
add_column :programs, :send_invitation_at, :datetime
end
end
モデル関連の設定とロジックの追加
次にモデルへ必要な設定を追加し、リンク生成のためのロジックも追加していきます。
UserParticipationモデル
class UserParticipation < ApplicationRecord
belongs_to :user
belongs_to :program
validates :user_id, uniqueness: { scope: :program_id }
end
いつも通りの中間テーブル用のアソシエーション追加ですね。
Userモデル
class User < ApplicationRecord
# 今回関係のない部分は省略
has_many :programs, dependent: :destroy
has_many :user_participations, dependent: :destroy
has_many :joined_programs, through: :user_participations, source: :program
# ランダムなトークンを生成
def self.new_token
SecureRandom.urlsafe_base64
end
# 渡された文字列のハッシュ値を返す
def self.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
end
アソシエーションの追加とトークン生成のためのメソッドを二つ追加しておきます。
今回の場合は初期にgemなしで認証機能を実装していた時の名残でself.digest
、self.new_token
が残っていたのでこちらを流用してます。
user関連で使わないならこれはprogramに移動したほうがよさげだと思います。
- self.new_token
Ruby標準ライブラリ「SecureRandom」のurlsafe_base64
メソッドを用いてランダムな文字列を生成します。
64種類の文字からなる長さ22の文字列なので、2つのトークンがたまたま完全に一致する(衝突する)ことはまずありません。
urlsafe_base64メソッドは、元々はURLを安全にエスケープするために用いられるメソッドらしいです。
- self.digest(string)
Railsをインストールすると最初から導入されている「BCrypt」というgemを用いて渡された文字列をハッシュ化し、返します
Programモデル
class Program < ApplicationRecord
attr_accessor :invitation_token # トークンを一時的に保存しておくための仮想的な属性
belongs_to :user
has_many :user_participations, dependent: :destroy
has_many :participants, through: :user_participations, source: :user
# その他今回関係のない部分は省略
# トークンを生成、ハッシュ化して保存。その時の時間も保存する
def create_invitation_digest
self.invitation_token = User.new_token
update_attribute(:invitation_digest, User.digest(invitation_token))
update_attribute(:send_invitation_at, Time.zone.now)
end
# トークンが正しいものか確認するメソッド
def authenticated?(invitation_token)
return false if invitation_digest.nil?
BCrypt::Password.new(invitation_digest).is_password?(invitation_token)
end
# トークンが有効期限内かどうか確認するメソッド
def invitation_expired?
send_invitation_at < 3.days.ago
end
# viewで招待リンクの有効期限を表示するためのフォーマット調整
def expiration_time
(send_invitation_at + 3.days).strftime("%Y年%m月%d日 %H:%M")
end
end
こちらもアソシエーションの追加とリンクに含まれるトークン関連のロジックを追加しています。
- create_invitation_digest
トークンを生成し実際にはDBに保存はしませんが、仮想的な属性のinvitation_token
にいったん保存してクラスのインスタンスに関連づいているものとして簡単に呼び出せるようにします。(program.invitation_token
)
そしてそのトークンをUser.digest(invitation_token)
でハッシュ化したものをDBに実際に保存します。
同時に、有効期限を設定するためにトークンを生成した時間もDBに記録しておきます。
このメソッドで保存まで行ってしまうので、新しいものが生成されるたびに古いものが上書きされて無効になってきます。
招待リンクが外部に漏れてしまった場合もリンクを新たに生成すれば即座に漏れたリンクを無効にできます。
- authenticated?(invitation_token)
gem 「BCrypt」の内部実装を参考にしたもので、invitation_token
をハッシュ化したものとinvitation_digest
に保存されているものを比べて同一のものであればtrue
を返します。
ルーティング、コントローラー、ビューの追加
ルーティングの追加
まずはルーティングの追加です。
programsのリソースにネストさせてinvitationsのリソースを定義します。
/programs/:program_id/invitations/new
のような感じのURLになり、params[:program_id]
で簡単にprogramのidを取得することができます。
Rails.application.routes.draw do
# 関係ない部分は省略
resources :programs do
resources :invitations, only: %i[ show new create edit update ]
end
# 省略
end
Invitationsコントローラーの追加
class InvitationsController < ApplicationController
# 一部省略してあります。認可機能を追加してください。
before_action :set_program, only: %i[ show new create edit update]
before_action :valid_user, only: %i[ edit update ]
# gem deviseのメソッドでログインしているかを確認
before_action :authenticate_user!, only: %i[ show new create edit update ]
before_action :check_expiration, only: %i[ update ]
def show
@expiration_time = @program.expiration_time
end
def new; end
def create
@program.create_invitation_digest
flash[:notice]= "招待リンクを作成しました"
redirect_to program_invitation_path(@program, @program.invitation_token)
end
def edit; end
def update
participation = UserParticipation.new(user: current_user, program: @program)
if participation.save
flash[:notice]= "#{@program.title}の制作に参加しました"
redirect_to @program
else
redirect_to @program, danger: "既に制作に参加しています"
end
end
private
def set_program
@program = Program.find(params[:program_id])
end
def check_expiration
if @program.invitation_expired?
flash[:danger] = "招待リンクが期限切れです"
redirect_to root_path
end
end
def valid_user
unless @program && @program.authenticated?(params[:id])
redirect_to root_url
end
end
end
先ほどモデルに追加したメソッドを使って
- 招待リンク生成用のページに遷移(new)
- 招待リンクを生成しそれを表示(create → show)
- 招待されたユーザーは招待リンクにアクセスすると参加確認ページへ(edit)
- 参加確認ページの「参加する」ボタンを押すと実際にDBにprogramと現在のユーザーの関連付けが保存される(update)
という感じで実装しています。
before_action :valid_user
やbefore_action :check_expiration
でアクションの前に招待リンクが有効なものか確認しています。
ここで設定されているもの以外にも gem pundit などを用いて適切な認可機能を追加し、許可されたユーザー以外からアクセスされないようにしてください
招待機能関連のビューの追加
<% content_for :title, "番組制作招待" %>
<div>
<%= render 'shared/flash_message' %>
<div>
<h2>
<%= @program.title %>
</h2>
<h3>この番組への招待リンクを生成しますか?</h3>
<%= button_to program_invitations_path do %>
招待リンクを生成する
<% end %>
</div>
</div>
<% content_for :title, "番組制作招待" %>
<div>
<%= render 'shared/flash_message' %>
<div>
<h2>
招待リンク
</h2>
<div>
<%= "招待リンクは#{@expiration_time}に期限切れになります" %>
</div>
<div>
<div>
<h3>注意事項</h3>
<ul>
<li>期限切れになった場合は再度招待リンクを生成してください</li>
<li>招待リンクは3日が経過するか再度リンクを生成すると古いものが期限切れになります</li>
<li>招待したい相手以外には伝えないように気を付けてください</li>
</ul>
</div>
</div>
</div>
</div>
<% content_for :title, "番組制作参加" %>
<div>
<%= render 'shared/flash_message' %>
<div>
<h1>
<%= @program.title %>
</h1>
<h2>この番組の制作に参加しますか?</h2>
<%= button_to program_invitation_path, method: :patch do %>
参加する
<% end %>
</div>
</div>
erbは不要なclass、ボタン等を省略してあります。
完成!
ここまで設定し終わると以下のような感じで招待リンクを生成
そのリンクからの参加が可能になります
招待リンク生成画面
招待リンクからの参加
必要に応じて参加したユーザーが確認できるように一覧等も実装しておくといいですね!
まとめ
- 招待リンクはパスワードリセット機能の応用で簡単に実装できる
- 「SecureRandom」を用いてランダムなトークンを作成、「BCrypt」でそのハッシュ化と有効性の確認を行う
今回この招待機能を自分で実装してみたことによってパスワードリセット機能の復習とgemなどの深堀りをすることができてとても勉強になりました。
ただ実装するだけではなく気になったところを深堀りしてまとめておくと、こんな感じでほかの部分で応用したりといったことができるようになるのでおすすめです!
ちなみに、メールでの招待機能を実装するのであれば gem devise_invitable ってのもあるみたいです。
参考資料
Rails tutorial 9章、12章