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

認証は完成しましたが、結果がフロント側で解らない。切断されるだけ。
切断されるとフロントは再接続を繰り返すので、次回は結果を通知する仕組みを考えます。


今回のコミットログ
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/6bcd4e77923c700d0acf1c67beeebdcde6ee5719

WebSocket(Action Cable)の認証方法を考えて実装する” に対して2件のコメントがあります。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です