Madogiwa Blog

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

Ruby on Rails: passwordlessを使ってMagicLink的な認証機能を作る

Ruby on RailsでMagic Link的な認証を作ってみたく調べてたところpasswordlessというgemを見つけて色々触ってみたので使い方とかをメモしておきます📝

github.com

Magic Linkとは?

サイトに用意された「マジックリンク」と名付けられたボタンを押します。すると、登録したメールアドレスにログインのためのURLが送られ、それをクリックすることでパスワードを入力することなく、ログイン処理が行えるのです。 ユーザーが持つ「メールアドレス」を鍵としたログインの仕組みです。

www.itmedia.co.jp

Slackで使われているアレですね👀 ユーザーがパスワードを覚えなくてもいいのも良いですが、サービスとしてパスワードを抱えなくても良いというのも運営側からするとメリットなのかなと思いました。

passwordlessの使い方

上記のMagic LinkをRailsで実現するためのgemがpasswordlessです。

github.com

Rails engineを使って作成されています。

READMEに記載の通りですが、使い方は簡単でGemfileに以下を追記後bundle installを実行し、

gem 'passwordless'

passwordlessが使用するtebleと認証したいmodel(User等)を作成します。

$ bin/rails passwordless:install:migrations
$ bin/rails generate model User email
# bin/rails db:migrate

あとはModel側にpasswordlessを使用するための設定を入れて、

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: { case_sensitive: false }

  passwordless_with :email # <-- here!
end

routesでpasswordless_forを使用してengineをmountします。

Rails.application.routes.draw do
  passwordless_for :users

  # other routes
end

ref: https://github.com/mikker/passwordless/blob/master/lib/passwordless/router_helpers.rb#L19

これで準備OKです👍

実際にMagic Linkを送信する場合は以下のようにユーザーを作成したあとに、

User.crete(email: 'foo@example.com')

/sign_inに遷移し、入力フォームにfoo@example.comを入力しサブミットするとマジックリンクが記載されたメールがユーザーに送信されます📧

Here's your link: http://localhost:3000/sign_in/MAGIC_LINK_TOKEN

Tips

ログイン必須にしたい

以下のような形で出来ました。マジックリンクの送信画面もログイン必須にならないようにskip_before_action :authenticate_user!, if: :passwordless_controller?を入れています。

class ApplicationController < ActionController::Base
  include Passwordless::ControllerHelpers

  before_action :authenticate_user!
  skip_before_action :authenticate_user!, if: :passwordless_controller?
  
  helper_method :current_user

  def current_user
    @current_user ||= authenticate_by_session(User)
  end

  def authenticate_user!
    return if current_user
    redirect_to sign_in_path, flash: { alert: 'ログインしてください!' }
  end
end

マジックリンク送信時のメールの文面を変更したい

app/views/passwordless/mailer/magic_link.text.erbを作成して、オーバーライドしてあげればOKです🙆‍♂️

Custom Message!!

<%= I18n.t('passwordless.mailer.magic_link', link: @magic_link) %>

railsguides.jp

ユーザー作成と合わせてマジックを送信したい

Passwordless.after_session_saveを使うと任意のタイミングでマジックリンクのURLをメールで送信出来るのでユーザー作成後に送信してあげるようにすると良さそうです。

class UsersController < ApplicationController
  skip_before_action :authenticate_user!

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    ActiveRecord::Base.transaction do
      if @user.save
        session = build_passwordless_session(@user)
        Passwordless.after_session_save.call(session, request) if session.save
        redirect_to sign_up_path, flash: { notice: 'ログイン用のリンクをメールで配信しました!' }
      else
        render :new
      end
    end
  end

  private

  def user_params
    params.require(:user).permit(:email)
  end
end

github.com

マジックリンク送信後のリダイレクト先を変えたい

先程の例と同様ですがPasswordless.after_session_saveを使うと任意のタイミングでマジックリンクのURLをメールで送信出来るので、独自のマジックリンク送信用のエンドポイントを用意してリダイレクト先を指定してあげると良さそうです。

class MagicLinksController < ApplicationController
  before_action :authenticate_user!

  def create
    session = build_passwordless_session(current_user)
    Passwordless.after_session_save.call(session, request) if session.save
    redirect_to mypage_path, flash: { notice: 'ログイン用のリンクをメールで配信しました!' }
  end
end

一度使用されたマジックリンクは無効化したい

config/initializers/passwordless.rbを用意して以下の指定をしてあげればOKです。

Passwordless.restrict_token_reuse = true

github.com

おわりに

パスワードレスコードも割と読みやすくでコアなロジックが切り出してあり、色々カスタマイズしやすい感じで良いですね✨

参考

ohbarye.hatenablog.jp

medium.com

Ruby on Rails: webpack管理のフロントエンドまわりのファイルも含めてViewを作成するジェネレーターを作った

個人のサービスでwebpackでフロントエンド関連のファイルを管理していてsimpackerを使ってRailsで読み込むみたいなことを行なっているのですが。。。

新規のページをつくるときとかに手でエントリーのJSを用意してページ用のCSSファイルを作ってインポートして、View側にjavascript_pack_tagといった読み込み用のヘルパーを記載するのが面倒だったので、ジェネレーターを自作してコマンド実行でそれらを行えるようにしました。

作成したジェネレーター

作成したのは以下のようなview_with_front_endで引数にcontrollerの名前とactionの配列を渡すとview及びエントリーの.ts,.scssを作成してくれるジェネレーターです。

$ bin/rails g view_with_front_end foo index show
      create  app/javascript/packs/foo/index.ts
      create  app/javascript/stylesheets/foo/index.scss
      create  app/views/foo/index.html.erb
      create  app/javascript/packs/foo/show.ts
      create  app/javascript/stylesheets/foo/show.scss
      create  app/views/foo/show.html.erb

実際に作成されるファイルは以下のような形でviewでは、javascript_pack_tagstylesheet_pack_tagで作成したscsstsをデフォルトで読み込むようにしてくれます。

import "@css/foo/index.scss";

console.log("foo/index");
h1 {
  color: blue;
}
<%= stylesheet_pack_tag 'foo/index' %>
<%= javascript_pack_tag 'foo/index', defer: true %>
<h1>foo/index</h1>

実際のコードは以下の通りです。環境によって変更されるであろう値(エントリーのjsファイルのパス等)は、optionで指定できるようにしています。

# frozen_string_literal: true

class ViewWithFrontEndGenerator < Rails::Generators::NamedBase
  VIEW_EXTENSION = 'erb'
  ENTRY_JS_PATH = 'app/javascript/packs'
  ENTRY_JS_EXTENSION = 'ts'
  CSS_PATH = 'app/javascript/stylesheets'
  CSS_EXTENSION = 'scss'
  CSS_PATH_ALIAS = '@css'

  argument :actions, type: :array, default: [], banner: 'action action'
  class_option :view_extension, type: :string, default: VIEW_EXTENSION
  class_option :entry_js_path, type: :string, default: ENTRY_JS_PATH
  class_option :entry_js_extension, type: :string, default: ENTRY_JS_EXTENSION
  class_option :css_path, type: :string, default: CSS_PATH
  class_option :css_extension, type: :string, default: CSS_EXTENSION
  class_option :css_path_alias, type: :string, default: CSS_PATH_ALIAS

  desc <<~TEXT
    Description:
        Create View with FrontEnd files(JS/CSS).
    Example:
        $ bin/rails g view_with_front_end foo index show
          create  app/javascript/packs/foo/index.ts
          create  app/javascript/stylesheets/foo/index.scss
          create  app/views/foo/index.html.erb
          create  app/javascript/packs/foo/show.ts
          create  app/javascript/stylesheets/foo/show.scss
          create  app/views/foo/show.html.erb
  TEXT

  def create_files
    build_instance_valiables(options)
    actions.map do |action|
      create_file "#{@entry_js_path}/#{@resource}/#{action}.#{@entry_js_extension}", js_file_body(action)
      create_file "#{@css_path}/#{@resource}/#{action}.#{@css_extension}", css_file_body
      create_file "app/views/#{@resource}/#{action}.html.#{@view_extension}", view_file_body(action)
    end
  end

  private

  def build_instance_valiables(options)
    @resource = file_name
    @view_extension = options['view_extension']
    @entry_js_path = options['entry_js_path']
    @entry_js_extension = options['entry_js_extension']
    @css_path = options['css_path']
    @css_extension = options['css_extension']
    @css_path_alias = options['css_path_alias']
  end

  def js_file_body(action)
    <<~ERB
      import "#{@css_path_alias}/#{@resource}/#{action}.#{@css_extension}";

      console.log("#{@resource}/#{action}");
    ERB
  end

  def css_file_body
    <<~ERB
      h1 {
        color: blue;
      }
    ERB
  end

  def view_file_body(action)
    <<~ERB
      <%= stylesheet_pack_tag '#{@resource}/#{action}' %>
      <%= javascript_pack_tag '#{@resource}/#{action}', defer: true %>
      <h1>#{@resource}/#{action}</h1>
    ERB
  end
end

ジェネレーターを自作するときの注意事項

ガイド通りに作るとlib配下をeager_loadingするようにしていると、自動読み込み周りでエラーが発生しました。。。

railsguides.jp

以下の対応が必要なようです。

# NOTE: generatorは自動読み込みでエラーになるのでzeitwerkによるautoloadの対象外にする
# https://github.com/rails/rails/issues/38671
Rails.autoloaders.main.ignore(Rails.root.join('lib/generators/**/*.rb'))

github.com

参考

railsguides.jp

Ruby on Rails: `constraints`を使ってログイン必須等のRoutesに制約を掛けるMEMO

SidekiqのDashboard等をmountしたengineの画面をログイン必須にしたい時、特定のサブドメインのときだけアクセス可能にしたい等、Rails のルーティングのconstraintsを使うと実現できそうだったので、そのあたりをメモしておきます📝

api.rubyonrails.org

Rails のルーティングのconstraintsとは?

constraintsとはRoutingになんかしらの条件を与え、条件に合致しない場合にはActionController::RoutingErrorを発生させることができる機能です。

railsguides.jp

以下のような形でサブドメインがadminのときの場合だけアクセス化にしたり任意の制約を設定することが出来ます。

namespace :admin do
  constraints subdomain: 'admin' do
    resources :users
  end
end

Railsで必要なmatches?に応答できるオブジェクトを渡す方法があります。

また上記の通りmatches?に応答できるオブジェクトを渡して制約を設定することも出来ます。

mountしたengineの画面をログイン必須する

SidekiqのDashboard等をmountしたengineの画面をログイン必須にしたい時に、このconstraintを利用すると便利です。

以下のようにrequestsesisonからログインしている管理ユーザーのIDを取得して、そのユーザーが実際に登録されているユーザーの場合だけアクセス可能にするようなことが出来ます。

class AdminAuthConstraint
  def matches?(request)
    return false unless request.session[:admin_user_id]

    !!AdminUser.find_by(id: request.session[:admin_user_id])
  end
end

Rails.application.routes.draw do
  namespace :admin do
    require 'sidekiq/web'
    mount Sidekiq::Web, at: '/sidekiq', constraints: AdminAuthConstraint.new
  end
end

Deviseではこの辺をもっと簡単にできるauthenticatedが提供されていて以下のような形で行えるようになっています。

Rails.application.routes.draw do
    authenticated :admin_user do
      require 'sidekiq/web'
      mount Sidekiq::Web, at: '/sidekiq'
    end
  end
end

中身はwardenにアクセスしてスコープを考慮して認証済かどうかを確認し、ブロック引数があればそれを実行しているって感じみたいですね👀

def authenticated(scope = nil, block = nil)
  constraints_for(:authenticate?, scope, block) do
    yield
  end
end

def constraints_for(method_to_apply, scope = nil, block = nil)
  constraint = lambda do |request|
    request.env['warden'].send(method_to_apply, scope: scope) &&
      (block.nil? || block.call(request.env["warden"].user(scope)))
  end

  constraints(constraint) do
    yield
  end
end

github.com

参考

github.com

rails routing constraintsについて | 日々雑記

Ruby on Rails: `rails secret`でセキュアな文字列を生成するMEMO

今までsecret_key_base等を設定するときにわざわざ以下のような感じでconsoleを起動してセキュアな文字列を取得していたのですが、

$ bin/rails console
irb(main):010:0> SecureRandom.hex(64)
=> "ad56c1c986e397149281da99f6e268d4e3657d65a46600dcaeccd8a368655b141f23c0a4791466cd9d51552d2b1a3b78ec21bfdf67c94d77321ad065806b6bcb"

bin/rails secretで簡単に取得出来るのを知らなかったのでメモ📝

rails secretでセキュアな文字列を取得する

やりかたは簡単で以下のコマンドを実行するとセキュアな文字列が標準出力されます。

$ bin/rails secret
dc83908ca804b051809c1b565d20582d57ab77fae9dad8eb8935b36eba2e2437c28d8abd43d862d797989b9a58340b6776fe3441ed1c3d02c5d87ac7fe2a98fe

Railsガイドでも推奨されているようですね!

秘密鍵は十分に長く、かつランダムなものにしなければなりません。一意な秘密鍵を得るにはrails secretを使います。

Rails セキュリティガイド - Railsガイド

ちなみにSecureRandom.hex(64)を実行しているようです。

github.com

SecureRandom.hex(64)は128文字のHEX文字列(0~9,a~f)を出力するので以下の画像のLowercase Lettersに該当しそうですが128文字あれば安全そうですね。

f:id:madogiwa0124:20211002164740p:plain [OC] I hope you find this one more beautiful than the last - updated table on time to brute force passwords : dataisbeautiful

参考

saku.hatenadiary.com

Ruby on Rails: コンソール使用時に特定のモデルを読み取り専用にするMEMO

APIから取得した結果を保存するModelやユーザー情報を扱うModel、中間結果を保存するテーブル等、直接更新してしまうと危険なのでコンソールから起動するときにはReadonlyにしたいなぁと思い色々やり方を考えてみたのでメモしておきます📝

rails console -sもありますが、つけ忘れたりするのと、トランザクションをコミットしないだけなのでトランザクション分離レベルによっては影響する可能性があるのかなぁと思いまして。Readonlyの例外はクエリ発行前に発生しそうなのでより安全なのかなと。

github.com

実際のコード

本体のコードは以下の通りでActiveRecord#readonly!を参考にreadonly?をtrueで返すModelReadonly::Readonlyを用意して、ModelReadonly::Service.call!を呼び出したときに引数で指定したModel + ファイルに設定したModelにModelReadonly::ReadonlyをincludeしてReadonlyにしています。

# frozen_string_literal: true

# NOTE: ActiveRecord#readonly!を参考にreadonly?でtrueを返却し読み取り専用にしてる。
# ※touch等は防げないので完全ではないため注意
# * https://github.com/rails/rails/blob/11c2c0ef2f5e3382fa95fc0e9e94835f74a31702/activerecord/lib/active_record/core.rb#L689-L691
# * https://github.com/rails/rails/blob/11c2c0ef2f5e3382fa95fc0e9e94835f74a31702/activerecord/lib/active_record/core.rb#L641-L644
module ModelReadonly::Readonly
  def readonly?
    true
  end
end

class ModelReadonly::Service
  class InvalidSettings < StandardError; end

  def self.call!(models: [], config_path: nil)
    raise InvalidSettings if models.empty? && config_path.nil?

    new(models: models + configured_models(config_path)).call
  end

  def self.configured_models(config_path)
    return [] if config_path.nil?

    configured_yml = YAML.load_file(config_path)
    configured_yml['readonly_models'].map(&:constantize)
  end

  def initialize(models: [])
    @models = models
  end

  attr_reader :models

  def call
    models.each do |model|
      model.class_eval { include ModelReadonly::Readonly }
    end
  end
end

あとはinitializer内で以下のような形で利用して上げればOKです🙆‍♂️

# frozen_string_literal: true

# NOTE: 本番環境では誤操作防止のため特定のモデルをコンソール起動時にReadonlyにする。
if Rails.env.production?
  Rails.application.configure do
    console { ModelReadonly::Service.call!(models: [Entry]) }
  end
end

ファイルを用意して以下のような形でもOKです🙆‍♂️

readonly_models:
- Entry
# frozen_string_literal: true

# NOTE: 本番環境では誤操作防止のため特定のモデルをコンソール起動時にReadonlyにする。
if Rails.env.production?
  Rails.application.configure do
    console do
      config_path = Rails.root.join('lib/model_readonly/readonly_models.yml')
      ModelReadonly::Service.call!(config_path: config_path)
    end
  end
end

以下のような形で更新しようとしたときにReadOnlyRecordの例外が発生します🚨

irb(main)> Entry.last.update(title: "foo")
Feed is marked as readonly (ActiveRecord::ReadOnlyRecord)

参考

hai3.net

railsguides.jp

Ruby on Rails: 複数DB利用時のDeviseのActiveRecord::ReadOnlyErrorに対応するメモ

認証にDeviseを利用しているとロック解除等のメールに送信されたLinkをクリックといったGETリクエストでDBを更新するような処理実行時にRailsの複数DBを使っているとActiveRecord::ReadOnlyErrorが発生してしまうため、そのあたりの対応方法をメモ📝

railsguides.jp

対応方法

Deviseが提供しているdevise_controller?メソッドを利用してDeviseのControllerであれば一律writingに接続するようにするConcernを用意してDevise.parent_controller(デフォルトではApplicationController)にincludeするようにしてみました。

module DeviseWritable
  extend ActiveSupport::Concern

  included do
    around_action :connected_to_writing, if: :devise_controller?
  end

  def connected_to_writing(&block)
    ActiveRecord::Base.connected_to(role: :writing, &block)
  end
end

class ApplicationController < ActionController::Base
  include DeviseWritable
end

個別にDeviseのController及びModelをオーバーライドしてconnected_toで接続先をwriteにしてあげてもいいのですが、影響範囲が結構広いため、そこまで厳密にRead/Writeを切り変えたいケースでなければ良いのかなと・・・!

参考

github.com

Rails 7から導入される予定のimportmap-railsを使ったフロントエンド環境を試してみる

Rails 7からimportmap-railsを使ったフロントエンド環境が提供される予定です。

Rails new時にimportmap-railsを使用するoptionも追加され試しやすかったので、軽く使ってみました。

利用方法とかメモしておきます📝

github.com

github.com

import mapsとは

簡単にいうと以下のような定義を元に、

<script type="importmap">
{
  "imports": {
    "moment": "/node_modules/moment/src/moment.js",
    "lodash": "/node_modules/lodash-es/lodash.js"
  }
}
</script>

以下のようにimportを表現出来る機能なようです。

import moment from "moment";
import { partition } from "lodash";

詳細な説明は以下を参照してください🙇‍♂️

github.com

これによりwebpackのようなbuildツールを使わなくてもimportを利用することが出来るのと、HTTP/2により並列で取得出来るファイル数に制限がなくなった。IE11のEOLも迫りES6が動かないブラウザはほとんど無いということで、importmapを利用した環境を採用し、Railsからnodeの依存を外そうということでimportmap-railsを使ったフロントエンド環境が候補に上がったようです(?)

world.hey.com

importmap-railsを使ってRailsでimportmapでフロントエンド環境を構築してみる。

今回は簡単なReactのコンポーネントimport mapsを使ってレンダリング出来るとことまでをやってみようと思います。

importmap-railsを使うにはrailsmainブランチからinstall後に以下のコマンドを実行することでimportmap-railsを導入した状態でRails newを行う事ができます🚃

$ rails new --javascript=importmap

Rails newしたあとに以下のコマンドを実行しimportmap-railsのセットアップを行います。

bin/rails importmap:install
Add Importmap include tags in application layout
      insert  app/views/layouts/application.html.erb
Create application.js module as entrypoint
      create  app/javascript/application.js
Ensure JavaScript files are in the asset pipeline manifest
      append  app/assets/config/manifest.js
Configure importmap paths in config/importmap.rb
      create  config/importmap.rb
Copying binstub
      create  bin/importmap

importmap-railsは現状assets:precompileでbuildしたjsを読み込むような挙動になっているようです👀

github.com

install後にbin/importmap pin nameを使ってライブラリのCDNのURLを登録します。

$ bin/importmap pin react-dom
Pinning "react-dom" to https://ga.jspm.io/npm:react-dom@17.0.2/index.js
Pinning "object-assign" to https://ga.jspm.io/npm:object-assign@4.1.1/index.js
Pinning "react" to https://ga.jspm.io/npm:react@17.0.2/index.js
Pinning "scheduler" to https://ga.jspm.io/npm:scheduler@0.20.2/index.js

このときのライブラリの名前とCDNのURLの解決は以下が利用されているようです👀

jspm.org

github.com

実際に登録されているライブラリはconfig/importmap.rbで見ることが出来ます📦

pin "application"
pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js"
pin "object-assign", to: "https://ga.jspm.io/npm:object-assign@4.1.1/index.js"
pin "react-dom", to: "https://ga.jspm.io/npm:react-dom@17.0.2/index.js"
pin "scheduler", to: "https://ga.jspm.io/npm:scheduler@0.20.2/index.js"

これらの登録したライブラリはjavascript_importmap_tagsで読み込まれます。

<!DOCTYPE html>
<html>
  <head>
    <title>SampleApp</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

これでライブラリを登録できたので適当なページを用意してapplicaton.jsにReactのコードを記載してみます。

<h1>TOP</h1>

<div id="root">
</div>
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails

console.log("application.js");

import ReactDOM from "react-dom";

ReactDOM.render("Hello, world!", document.getElementById("root"));

以下の通りimport-maps利用したインポートが行われ

<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application-e92f8138755b2135e2a621bfbb917dc7f07214686408cf58ca18cf919cf1cc72.js",
    "react": "https://ga.jspm.io/npm:react@17.0.2/index.js",
    "object-assign": "https://ga.jspm.io/npm:object-assign@4.1.1/index.js",
    "react-dom": "https://ga.jspm.io/npm:react-dom@17.0.2/index.js",
    "scheduler": "https://ga.jspm.io/npm:scheduler@0.20.2/index.js"
  }
}</script>
<link rel="modulepreload" href="/assets/application-e92f8138755b2135e2a621bfbb917dc7f07214686408cf58ca18cf919cf1cc72.js">
<link rel="modulepreload" href="https://ga.jspm.io/npm:react@17.0.2/index.js">
<link rel="modulepreload" href="https://ga.jspm.io/npm:object-assign@4.1.1/index.js">
<link rel="modulepreload" href="https://ga.jspm.io/npm:react-dom@17.0.2/index.js">
<link rel="modulepreload" href="https://ga.jspm.io/npm:scheduler@0.20.2/index.js">
<script src="/assets/es-module-shims-c7d42ad90c35e70ec01698a6f481a4eacabafe8c30cae13fa50c7c0d287a1563.js" async="async" data-turbo-track="reload"></script>
<script type="module">import "application"</script>

コンポネントも表示されています🎉

f:id:madogiwa0124:20210905135456p:plain

おわりに

小規模でJSも少しだけしか使わないケースにはnodeの依存もなくせて、良いかもですね👀

参考

www.youtube.com