Rails における BASIC 認証

Rails における BASIC 認証について調べたのでその記録です。

シンプルなケース

https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic.html にあるように http_basic_authenticate_with を使うのが最もシンプルな実装でしょう。

 class PostsController < ApplicationController
   http_basic_authenticate_with name: "dhh", password: "secret", except: :index

   def index
     render plain: "Everyone can see me!"
   end

   def edit
     render plain: "I'm only accessible if you know the password"
   end
end

ただし、これだと一組の name, password しか設定できません。

発展的なケース

業務上の理由で、複数の name, password の組に対してBASIC認証を設定したいシーンがありました。

改めて https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic.html を読んでみると、"Advanced Basic example" として次のコードが紹介されていました。

class ApplicationController < ActionController::Base
  before_action :set_account, :authenticate

  private
    def set_account
      @account = Account.find_by(url_name: request.subdomains.first)
    end

    def authenticate
      case request.format
      when Mime[:xml], Mime[:atom]
        if user = authenticate_with_http_basic { |u, p| @account.users.authenticate(u, p) }
          @current_user = user
        else
          request_http_basic_authentication
        end
      else
        if session_authenticated?
          @current_user = @account.users.find(session[:authenticated][:user_id])
        else
          redirect_to(login_url) and return false
        end
      end
    end
end

リクエストの format が xmlatom のときに BASIC 認証を要求しているようですね。

authenticate_with_http_basicrequest_http_basic_authentication が初見でした。

authenticate_with_http_basic について

https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic/ControllerMethods.html#method-i-authenticate_with_http_basic を読んでみると

# File actionpack/lib/action_controller/metal/http_authentication.rb, line 96
def authenticate_with_http_basic(&login_procedure)
  HttpAuthentication::Basic.authenticate(request, &login_procedure)
end

とありました。なるほど HttpAuthentication::Basic.authenticate ってなんですか。

https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic.html#method-i-authenticate ですね。

# File actionpack/lib/action_controller/metal/http_authentication.rb, line 105
def authenticate(request, &login_procedure)
  if has_basic_credentials?(request)
    login_procedure.call(*user_name_and_password(request))
  end
end

「BASIC 認証の認証情報があるなら login_procedurecall するぜ (ブロックとして渡された認証手続きを実行するぜ)」と言ってますね。

「そのブロックに渡す引数は *user_name_and_password な!」とも言ってます。

user_name_and_password はその名の通り BASIC 認証で入力された user_name と password ですね。slat演算子 * がついてることからも配列だと分かります。コードはこちらの通り https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic.html#method-i-user_name_and_password

# File actionpack/lib/action_controller/metal/http_authentication.rb, line 115
def user_name_and_password(request)
  decode_credentials(request).split(":", 2)
end

ここまでの情報をもとに、サンプルコードのこれ↓がなにをやっているのかを整理すると

if user = authenticate_with_http_basic { |u, p| @account.users.authenticate(u, p) }
  @current_user = user
else
  # ...
end
  • authenticate_with_http_basic { |u, p| ... }
    • リクエストにBASIC認証情報があるならば、その user_name と password (それぞれ変数 u, p )をつかってブロック内のなんらかの処理をする
    • リクエストにBASIC認証情報がないならば nil を返す
  • if user = ...
    • 上記の処理が真ならば(user に何らかの真な値が入るならば)
  • @current_user = user
    • current_user に user を入れる

という感じですね。

request_http_basic_authentication について

https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic/ControllerMethods.html#method-i-request_http_basic_authentication を読んでみると

# File actionpack/lib/action_controller/metal/http_authentication.rb, line 100
def request_http_basic_authentication(realm = "Application", message = nil)
  HttpAuthentication::Basic.authentication_request(self, realm, message)
end

とのこと。はーい、 HttpAuthentication::Basic.authentication_request ってなんですか

https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic.html#method-i-authentication_request

def authentication_request(controller, realm, message)
  message ||= "HTTP Basic: Access denied.\n"
  controller.headers["WWW-Authenticate"] = %(Basic realm="#{realm.tr('"', "")}")
  controller.status = 401
  controller.response_body = message
end

401 を返して BASIC 認証を要求しているってことですね。

BASIC 認証自体については HTTP 認証 - HTTP | MDN を参照のこと。

さて、改めてサンプルコードに戻ると

if user = authenticate_with_http_basic { |u, p| @account.users.authenticate(u, p) }
  @current_user = user
else
  request_http_basic_authentication
end

authenticate_with_http_basic が偽だったら BASIC 認証を要求する、ということですね。

これらを使って複数の user_name, password の BASIC 認証を実装してみる

class ApplicationController < ActionController::Base
  before_action :authenticate

  private
    def authenticate
      credentials = [%w[name1 password1], %w[name2 password2]]

      unless authenticate_with_http_basic { |u, p| credentials.include?([u, p]) }
        request_http_basic_authentication
      end
    end
end

こんな感じですかねぇ

さらに便利メソッドがある

この記事を書きながら調べてたら authenticate_or_request_with_http_basic とかいうメソッドを見つけました。 名前からしてそうって感じなんですが https://api.rubyonrails.org/v7.0.4.2/classes/ActionController/HttpAuthentication/Basic/ControllerMethods.html#method-i-authenticate_or_request_with_http_basic を読むと

# File actionpack/lib/action_controller/metal/http_authentication.rb, line 92
def authenticate_or_request_with_http_basic(realm = nil, message = nil, &login_procedure)
  authenticate_with_http_basic(&login_procedure) || request_http_basic_authentication(realm || "Application", message)
end

ということで、さっきまで初見だった2つのメソッドを組み合わせたメソッドなんですね。

なので、↑で実装したコードを書き換えると

class ApplicationController < ActionController::Base
  before_action :authenticate

  private
    def authenticate
      credentials = [%w[name1 password1], %w[name2 password2]]

      authenticate_or_request_with_http_basic { |u, p| credentials.include?([u, p]) }
    end
end

と書けそうですね。動作確認してないので動かなかったら教えて下さい。


24時を過ぎて数分経ってしまいましたが、これは1月の記事です。さっきお茶をこぼさなければ絶対に24時前に書き終えられたはずなんです、だれですかあんなところにお茶を置いたのは!

おやすみなさい 🍵


追記: 投稿日過去にできました。 https://i-am-an-easy-going.hatenablog.com/entry/2020/02/16/225916