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件のコメントがあります。