今更だけどRailsの基本認証について

手元で作ったRailsアプリを軽くデプロイして確認したかったので、基本認証入れて確認しようとしていましたが、 似たようなメソッドがいくつもあってどれだっけとなったり、良くない実装が検索結果に出てきたので、改めて整理しておきます。

ググる

以下のようなコードが紹介されています。

class ApplicationController < ActionController::Base
  before_action :basic_auth

  def basic_auth
    authenticate_or_request_with_http_basic do |username, password|
      username == ENV["BASIC_AUTH_USER"] && password == ENV["BASIC_AUTH_PASSWORD"]
    end
end

== での比較は timing attacks の脆弱性があります。

secure_compare使おう

secure_compare を使うというのが解なのですが、こちらの記事がめちゃくちゃ詳しく載っててるので割愛します。
 
参考)機密情報に関わる文字列の比較は == ではなく secure_compare を使おう

やり方おさらい

やり方1. http_basic_authenticate_with

simpleなやつ。こんな感じで使います。簡単で良いですね。

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

内部的には、before_actionで http_basic_authenticate_or_request_with を呼ぶ実装になっている。

# https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/http_authentication.rb#L76
def http_basic_authenticate_with(name:, password:, realm: nil, **options)
  raise ArgumentError, "Expected name: to be a String, got #{name.class}" unless name.is_a?(String)
  raise ArgumentError, "Expected password: to be a String, got #{password.class}" unless password.is_a?(String)
  before_action(options) { http_basic_authenticate_or_request_with name: name, password: password, realm: realm }
end

そして、http_basic_authenticate_or_request_with では、ActiveSupport::SecurityUtils.secure_compare を利用している。

# https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/http_authentication.rb#L83
def http_basic_authenticate_or_request_with(name:, password:, realm: nil, message: nil)
  authenticate_or_request_with_http_basic(realm, message) do |given_name, given_password|
  # This comparison uses & so that it doesn't short circuit and
  # uses `secure_compare` so that length information isn't leaked.
  ActiveSupport::SecurityUtils.secure_compare(given_name.to_s, name) &
    ActiveSupport::SecurityUtils.secure_compare(given_password.to_s, password)
  end
end

やり方2.http_basic_authenticate_or_request_with

独自の処理を噛ませたい場合は、当然ながら「1」で呼んでいる http_basic_authenticate_or_request_with を利用することで実現できる。

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

  private
    def authenticate
      # do something
     http_basic_authenticate_or_request_with(name: "dhh", password:  "secret")
    end

やり方3. authenticate_or_request_with_http_basic

また、当然ながら2で呼んでる authenticate_or_request_with_http_basic を呼ぶ実装でも良い。

# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L92
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

def authenticate_with_http_basic(&login_procedure)
  HttpAuthentication::Basic.authenticate(request, &login_procedure)
end

やり方4. authenticate_with_http_basic

またまた当然ながら3で呼んでる authenticate_with_http_basic を呼ぶ実装でも良い。 この場合は、基本認証を要求するレスポンスが自動で返らないので、必要に応じて request_http_basic_authentication (後述)を呼び出したりする必要がある。

Advanced Basic exampleとしてRailsのドキュメントにはこの例が記載されている。

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

request_http_basic_authentication

基本認証を要求するレスポンスを返す処理。

# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L100
def request_http_basic_authentication(realm = "Application", message = nil)
  HttpAuthentication::Basic.authentication_request(self, realm, message)
end
# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L100
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

まとめ

色々あってややこしいので軽くまとめておく。

No. メソッド名 secure_compareの利用の有無 認証不可時に基本認証を要求するレスポンスを返すかどうか 備考
1 http_basic_authenticate_with 返す                               単に基本認証するだけならこちらがおすすめ。
2 http_basic_authenticate_or_request_with 返す 独自の処理を追加したいのであれば、こちらがおすすめ。認証不可の場合は、 基本認証を要求するレスポンスを返す。
3 authenticate_or_request_with_http_basic 返す secure_compare が不要なケース。
4 authenticate_with_http_basic 返さない ブラウザからの入力を期待しないケース。もしくは、別途任意のタイミングでrequest_http_basic_authenticationを呼んで入力させたい場合。