Action CableでWebSocketを試す では、一旦、公式サイトを参考にCookieで認証するようにしましたが、ドメインの制約を受けるし、フロントからAPIで認証する場合にも都合が悪い。
認証方法を検討し、実装したのでメモしておきます。
今回は認可方式で、APIでtokenを発行する想定(後日作成)で、controllerで発行して、jsで接続直後のメッセージ取得と送信時にtoken検証するようにしました。
渡し方を考える
1. wsのURLパラメータに入れる → wssなら暗号化されるので悪くはないが。
2. リクエストヘッダーに入れる → 同様にwssなら。
3. 接続後にWebSocket通信で認証情報を渡す → これが良さそう!
Websocket の認証 (Authentication) について考える – nykergoto’s blog
渡す値を考える
1. 認証token → セッション使っている場合はtokenがない。あっても固定が前提になる。
2. 認証token(リクエスト毎一定時間毎に変わる場合) → 初回認証にしか使えない。変えてる意味が薄れる。
3. WebSocket用のtoken発行(認可) → これが良さそう!
APIで既存の認証を元に専用のtokenを発行し、接続後にWebSocket通信でtokenを渡して認可。
セッションを使っている場合は、controllerで発行し、viewでhiddenに埋め込んで、jsで値を使用。
WebSocketは到達が保証されないので、毎回や定期的に変えるのは現実的ではなさそう。
接続tokenに期限を設ける事で、繋ぎっぱなしを抑制できる。
但し、メッセージ送信や受信が発生する前提。pingではアプリは動いてなさそう。
定期的にjsでチェックしてもいいかも。もしくは、サーバー側から接続をチェックして切断(後日検討)
自動生成
接続tokenを保存するmodelをrails generate自動生成します。
% rails g model ws_token code:string user:references Running via Spring preloader in process 99041 invoke active_record create db/migrate/20220205014432_create_ws_tokens.rb create app/models/ws_token.rb invoke rspec create spec/models/ws_token_spec.rb invoke factory_bot create spec/factories/ws_tokens.rb
migrate修正
db/migrate/20220205014432_create_ws_tokens.rb
class CreateWsTokens < ActiveRecord::Migration[6.1] def change - create_table :ws_tokens do |t| + create_table :ws_tokens, id: false do |t| - t.string :code + t.string :code, primary_key: true, comment: 'コード' - t.references :user, null: false, foreign_key: true + t.references :user, type: :bigint, foreign_key: false, comment: 'ユーザーID' + t.datetime :auth_successed_at, comment: '認証成功日時' + t.datetime :last_auth_successed_at, comment: '最終認証成功日時' + t.string :last_status, comment: '最終ステータス' t.timestamps end end end
レコードの追加・削除が繰り返される想定なので、primary_keyをidからcodeに変更。
アクセスが多いならRedisとか使うのが良さそうですが、先ずはDBで。
app/models/ws_token.rb
class WsToken < ApplicationRecord - belongs_to :user + self.primary_key = :code + belongs_to :user, optional: true end
config/locales/ja.yml
ja: activerecord: models: + ws_token: 'WebSocketトークン' attributes: + ws_token: + code: 'コード' + user: 'ユーザー' + auth_successed_at: '認証成功日時' + last_auth_successed_at: '最終認証成功日時' + last_status: '最終ステータス' + created_at: '作成日時' + updated_at: '更新日時'
% rails db:migrate
トークン作成
controllerでトークンを作成して、viewに埋め込んで、jsで使えるようにします。
一緒にCookiesでしていた認証も削除。
app/controllers/top_controller.rb
class TopController < ApplicationController + before_action :set_message_token # GET / トップページ def index channels = ['all_channel'] - if user_signed_in? - channels += ["user_channel_#{current_user.code}"] - cookies.encrypted[:cable] = current_user.code # Tips: Action Cable用 - end + channels += ["user_channel_#{current_user.code}"] if user_signed_in? @messages = Message.where(channel: channels).limit(5).reverse + + private + # メッセージトークン発行 + def set_message_token + WsToken.where('created_at < ?', Time.current - Settings['token_cleaning_hours'].to_i.hours).delete_all # Tips: 発行後、一定時間以上経過したトークンを削除 + @message_token = create_unique_code(WsToken, 'code', "TopController.index #{current_user&.id}") + WsToken.create!(code: @message_token, user_id: current_user&.id) + end end
app/views/top/_message.html.erb
+ <%= hidden_field_tag :message_token, @message_token %>
- <%= javascript_pack_tag 'message' %>
javascript_pack_tagがbodyの中にあると、ページ遷移しても処理が動き続けてしまい、何重にも受信されてしまうので、headに移動(application.html.erb)。
app/views/layouts/application.html.erb
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %> + <%= javascript_pack_tag 'message', 'data-turbolinks-track': 'reload' if controller_name == 'top' %> </head>
app/channels/application_cable/connection.rb
module ApplicationCable class Connection < ActionCable::Connection::Base - identified_by :current_user - def connect logger.debug('==== connect') - code = cookies.encrypted[:cable] - self.current_user = User.find_by(code: code) if code.present? - # reject_unauthorized_connection if current_user.blank? # Tips: 未ログインでも使用する為、コメントアウト - logger.debug("current_user: #{current_user}") end end end
Cookiesでしていた認証は不要なので削除。
connect自体は通して、認証後にチャネルを割り当てる予定。
認証処理
app/javascript/packs/message.js
// 接続成功 or 再接続 -> 表示切り替え connected() { console.log('==== connected'); + messageChannel.perform('auth_request', { token: $('#message_token').val() });
フロントで接続されたらauth_requestを投げて認証依頼。
- messageChannel.sendMessage({ channel: channel, body: body }); + messageChannel.perform('send_message', { channel: channel, body: body, token: $('#message_token').val() });
send_messageにもtoken追加して、毎回認証する流れ。
$(function(){ + // ページ遷移 or 閉じる -> 切断 // Tips: 通常、ブラウザ側でも切断される + $(window).on('beforeunload', function() { + console.log('==== onbeforeunload'); + consumer.disconnect(); + }); });
序でにdisconnectを明示的に投げるようにしました。
app/channels/message_channel.rb
class MessageChannel < ApplicationCable::Channel def subscribed + logger.debug("==== subscribed: #{connection.statistics}") - logger.debug('==== subscribed') - logger.debug("current_user: #{current_user}") - - stream_from 'all_channel' - stream_from "user_channel_#{current_user.code}" if current_user.present? end def unsubscribed + logger.debug("==== unsubscribed: #{connection.statistics}") - logger.debug('==== unsubscribed') - logger.debug("current_user: #{current_user}") + if @ws_token.present? + @ws_token.last_status = 'unsubscribed' + @ws_token.save! + end end + + # 認証・再認証 + def auth_request(data) + logger.debug("==== auth_request: #{data}, #{connection.statistics}") + + error, ws_token = check_token(data['token']) + if error.present? + if ws_token.present? + ws_token.last_status = error + ws_token.save! + end + connection.close + # TODO: 失敗通知 + else + ws_token.last_auth_successed_at = Time.current + ws_token.auth_successed_at = ws_token.last_auth_successed_at if ws_token.auth_successed_at.blank? + ws_token.last_status = 'success' + ws_token.save! + add_stream_channel(ws_token.user) if @ws_token.blank? + # TODO: 成功通知 + end + + logger.debug("@ws_token: #{@ws_token.inspect} -> #{ws_token.inspect}") + @ws_token = ws_token + + error.blank? + end # メッセージ送信(受信) def send_message(data) logger.debug("==== send_message: #{data}") - logger.debug("current_user: #{current_user}") - return if current_user.blank? + return unless auth_request(data) - case data['channel'] - when 'all_channel' - channel = data['channel'] - when 'user_channel' - channel = "#{data['channel']}_#{current_user.code}" - else - return - end + channel = check_send_channel(data['channel'], @ws_token.user) + if channel.blank? + # TODO: 失敗通知 + return + end - message = Message.new(channel: channel, body: data['body'], user_id: current_user.id) + message = Message.new(channel: channel, body: data['body'], user_id: @ws_token.user_id) message.save! - data['channel_i18n'] = message.channel_i18n - data['user'] = current_user_info - data['created_at'] = message.created_at - data['created_at_i18n'] = I18n.l(message.created_at) - ActionCable.server.broadcast(channel, data) + ActionCable.server.broadcast(channel, send_message_data(message, @ws_token.user)) # TODO: 非同期 + # TODO: 成功通知 end private + + # トークンチェック + def check_token(token) + ws_token = nil + error = nil + + if token.blank? + error = 'blank' + elsif @ws_token.present? && @ws_token.code != token + error = 'different' + else + ws_token = WsToken.find_by(code: token) + if ws_token.blank? + error = 'notfound' + elsif ws_token.auth_successed_at.blank? && (ws_token.created_at < Time.current - Settings['token_expired_start_minutes'].to_i.minutes) + error = 'expired_start' # Tips: 発行後、一定時間以内に開始してない場合は無効 + elsif ws_token.created_at < Time.current - Settings['token_expired_hours'].to_i.hours + error = 'expired' # Tips: 発行後、一定時間以上経過したら無効 + end + end + + [error, ws_token] + end + + # チャネル追加 + def add_stream_channel(user) + stream_from 'all_channel' + stream_from "user_channel_#{user.code}" if user.present? + end + + # 送信チャネルチェック + def check_send_channel(channel, user) + return if user.blank? + + case channel + when 'all_channel' + channel + when 'user_channel' + "#{channel}_#{user.code}" + end + end + # 送信メッセージ - def current_user_info + def send_message_data(message, user) { - code: current_user.code, - image_url: { - mini: "#{Settings['base_image_url']}#{current_user.image_url(:mini)}", - small: "#{Settings['base_image_url']}#{current_user.image_url(:small)}", - medium: "#{Settings['base_image_url']}#{current_user.image_url(:medium)}", - large: "#{Settings['base_image_url']}#{current_user.image_url(:large)}", - xlarge: "#{Settings['base_image_url']}#{current_user.image_url(:xlarge)}" - }, - name: current_user.name + id: message.id, + channel: message.channel, + channel_i18n: message.channel_i18n, + body: message.body, + user: { + code: user.code, + image_url: { + mini: "#{Settings['base_image_url']}#{user.image_url(:mini)}", + small: "#{Settings['base_image_url']}#{user.image_url(:small)}", + medium: "#{Settings['base_image_url']}#{user.image_url(:medium)}", + large: "#{Settings['base_image_url']}#{user.image_url(:large)}", + xlarge: "#{Settings['base_image_url']}#{user.image_url(:xlarge)}" + }, + name: user.name + }, + created_at: message.created_at, + created_at_i18n: I18n.l(message.created_at) } end end
config/settings.yml
## WebSocketトークン # 作成日時から初回りクエストまでのトークン有効期限(単位:分) token_expired_start_minutes: 5 # 作成日時からトークン有効期限(単位:時間) token_expired_hours: 12 # 作成日時からの経過時間(単位:時間) token_cleaning_hours: 24
認証は完成しましたが、結果がフロント側で解らない。切断されるだけ。
切断されるとフロントは再接続を繰り返すので、次回は結果を通知する仕組みを考えます。
“WebSocket(Action Cable)の認証方法を考えて実装する” に対して2件のコメントがあります。