ruby-saml を使ってSSOを試してみる with LINE WORKS

こんにちは!kossyです!

さて、今回はSAML認証のクライアント側を実装できるGem「ruby-saml」でLINE WORKSのSAML2.0でSSOを試してみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina


saml-spレポジトリの作成

SAMLのServiceProvider側を作成するために、Railsアプリを作成します。今回はsaml-spというレポジトリ名とします。

$ rails new saml-sp -d postgresql -T

$ cd saml-sp

$ rails db:create

$ rails s

これでlocalhost:3000にアクセスしまして、毎度お馴染み「Yay!」の画面が表示されればOKです。

ruby-samlの導入

次に、Gemfileを修正します。

# Gemfile

gem 'ruby-saml'
gem 'figaro' # 環境変数を管理するために使います。必須ではないです

で bundle。

$ bundle

次にfigaroのinstallコマンドを叩きます。

$ bundle exec figaro install

      create  config/application.yml
      append  .gitignore

このコマンドで作成されたconfig/application.ymlファイルに秘密情報を記載していきます。


認証用のモデルを作成

次に認証用のAccountモデルを作成します。

$ rails g model Account

$ rails db:migrate

作成されたapp/models/account.rbに以下のメソッドを追加します。(中身が空ですが後ほど実装します。)

class Account < ApplicationRecord
  # 追加
  def self.get_saml_settings(url_base)
  end
end

routing/controller/viewsの定義

順に定義していきます。

# config/routes.rb

  resources :saml, only: :index do
    collection do
      get :sso
      post :acs
      get :metadata
      get :logout
    end
  end

  root 'saml#index'
# app/controllers/saml_controller.rb

class SamlController < ApplicationController
  skip_before_action :verify_authenticity_token, :only => [:acs, :logout]

  def index
    @attrs = {}
  end

  def sso
    settings = Account.get_saml_settings(get_url_base)
    if settings.nil?
      render :action => :no_settings
      return
    end

    request = OneLogin::RubySaml::Authrequest.new

    redirect_to(request.create(settings))
  end

  def acs
    settings = Account.get_saml_settings(get_url_base)
    response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: settings)

    if response.is_valid?
      session[:nameid] = response.nameid
      session[:attributes] = response.attributes
      @attrs = session[:attributes]
      logger.info "Sucessfully logged"
      logger.info "NAMEID: #{response.nameid}"
      render :action => :index
    else
      logger.info "Response Invalid. Errors: #{response.errors}"
      @errors = response.errors
      render :action => :fail
    end
  end

  def metadata
    settings = Account.get_saml_settings(get_url_base)
    meta = OneLogin::RubySaml::Metadata.new
    render :xml => meta.generate(settings, true)
  end

  # Trigger SP and IdP initiated Logout requests
  def logout
    # If we're given a logout request, handle it in the IdP logout initiated method
    if params[:SAMLRequest]
      return idp_logout_request

    # We've been given a response back from the IdP
    elsif params[:SAMLResponse]
      return process_logout_response
    elsif params[:slo]
      return sp_logout_request
    else
      reset_session
    end
  end

  # Create an SP initiated SLO
  def sp_logout_request
    # LogoutRequest accepts plain browser requests w/o paramters
    settings = Account.get_saml_settings(get_url_base)

    if settings.idp_slo_target_url.nil?
      logger.info "SLO IdP Endpoint not found in settings, executing then a normal logout'"
      reset_session
    else

      # Since we created a new SAML request, save the transaction_id
      # to compare it with the response we get back
      logout_request = OneLogin::RubySaml::Logoutrequest.new()
      session[:transaction_id] = logout_request.uuid
      logger.info "New SP SLO for User ID: '#{session[:nameid]}', Transaction ID: '#{session[:transaction_id]}'"

      if settings.name_identifier_value.nil?
        settings.name_identifier_value = session[:nameid]
      end

      relayState = url_for controller: 'saml', action: 'index'
      redirect_to(logout_request.create(settings, :RelayState => relayState))
    end
  end

  # After sending an SP initiated LogoutRequest to the IdP, we need to accept
  # the LogoutResponse, verify it, then actually delete our session.
  def process_logout_response
    settings = Account.get_saml_settings(get_url_base)
    request_id = session[:transaction_id]
    logout_response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings, :matches_request_id => request_id, :get_params => params)
    logger.info "LogoutResponse is: #{logout_response.response.to_s}"

    # Validate the SAML Logout Response
    if not logout_response.validate
      error_msg = "The SAML Logout Response is invalid.  Errors: #{logout_response.errors}"
      logger.error error_msg
      render :inline => error_msg
    else
      # Actually log out this session
      if logout_response.success?
        logger.info "Delete session for '#{session[:nameid]}'"
        reset_session
      end
    end
  end

  # Method to handle IdP initiated logouts
  def idp_logout_request
    settings = Account.get_saml_settings(get_url_base)
    logout_request = OneLogin::RubySaml::SloLogoutrequest.new(params[:SAMLRequest], :settings => settings)
    if not logout_request.is_valid?
      error_msg = "IdP initiated LogoutRequest was not valid!. Errors: #{logout_request.errors}"
      logger.error error_msg
      render :inline => error_msg
    end
    logger.info "IdP initiated Logout for #{logout_request.nameid}"

    # Actually log out this session
    reset_session

    logout_response = OneLogin::RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, :RelayState => params[:RelayState])
    redirect_to logout_response
  end

  def get_url_base
	"#{request.protocol}#{request.host_with_port}"
  end
end
# app/views/saml/fail.html.erb

<html>
  <body>
  <h4>SAML Response invalid</h4>
    <% if @errors %>
      <% @errors.each do |error| %>
        <p><%= error %></p>
      <% end %>
    <% end %>
  </body>
</html>
# app/views/saml/index.html.erb

<% if session[:nameid].present? %>
  <p>NameID: <%= session[:nameid] %></p>

  <% if @attrs.any? %>
    <p>Received the following attributes in the SAML Response:</p>
    <table><thead><th>Name</th><th>Values</th></thead><tbody>

    <% @attrs.each do |key,attr_value|  %>

      <tr><td><%= key %></td>
      <td>
      <% if attr_value.any? %>
          <ul>
          <% attr_value.each do |val| %>
              <li><%= val %></li>
          <% end %>
          </ul>
      <% end %>
      </td></tr>
    <% end %>
    </tbody>
    </table>
  <% end %>
  <p><%= link_to "Logout", :action => "logout" %></p>
  <p><%= link_to "Single Logout", :action => "logout", :slo => '1' %></p>

<% else %>
  <p><%= link_to "Login", :action=>"sso"%></p>
<% end -%>
# app/views/saml/logout.html.erb

<p>Logged out</p>

<p><%= link_to "Login", :action=>"sso"%></p>
# app/views/saml/no_settings.html.erb

<h2>No Settings found</h2>

ここまで実装できましたら、一旦動作の確認をしてみます。(get_saml_settingsメソッドが空なので当然意図通り動きませんが、、、)

localhost:3000 にアクセスすると、Loginというリンクがあるページが表示されます。

f:id:kossy-web-engineer:20211017201558p:plain

クリックすると以下の表示になるはずです。

f:id:kossy-web-engineer:20211017201617p:plain

saml_controller.rbのssoアクションのコードを見ると、

  def sso
    settings = Account.get_saml_settings(get_url_base)
    if settings.nil?
      render :action => :no_settings
      return
    end

    request = OneLogin::RubySaml::Authrequest.new

    redirect_to(request.create(settings))
  end

settingsがnilの場合はno_settingsをレンダリングするため、NoSettingのページに遷移しています。

あとはget_saml_settingsメソッドの中身を実装していくだけです。


get_saml_settingsメソッドの実装

では本丸のメソッドの実装です。

get_saml_settingsメソッドを以下のように書き換えてください。

  def self.get_saml_settings(url_base)
    # this is just for testing purposes.
    # should retrieve SAML-settings based on subdomain, IP-address, NameID or similar
    settings = OneLogin::RubySaml::Settings.new

    url_base ||= "http://localhost:3000"

    # Example settings data, replace this values!

    # When disabled, saml validation errors will raise an exception.
    settings.soft = true

    #SP section
    settings.issuer                         = url_base + "/saml/metadata"
    settings.assertion_consumer_service_url = url_base + "/saml/acs"
    settings.assertion_consumer_logout_service_url = url_base + "/saml/logout"
    settings.protocol_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" # 指定しないとGet Request で /saml/acs を叩いてくるため

    idp_base_url = "https://auth.worksmobile.com/saml2"

    # IdP section
    settings.idp_entity_id                  = "#{idp_base_url}/#{ENV['IDP_GROUP_NAME']}"
    settings.idp_sso_target_url             = "#{idp_base_url}/idp/#{ENV['IDP_GROUP_NAME']}"
    settings.idp_slo_target_url             = "#{idp_base_url}/idp/#{ENV['IDP_GROUP_NAME']}/logout"
    settings.idp_cert_fingerprint           = "#{ENV['IDP_FINGERPRINT']}"
    settings.idp_cert_fingerprint_algorithm = XMLSecurity::Document::SHA256

    settings.name_identifier_format         = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"

    # Security section
    settings.security[:authn_requests_signed] = false
    settings.security[:logout_requests_signed] = false
    settings.security[:logout_responses_signed] = false
    settings.security[:metadata_signed] = false
    settings.security[:digest_method] = XMLSecurity::Document::SHA1
    settings.security[:signature_method] = XMLSecurity::Document::RSA_SHA1

    settings
  end

ほとんどruby-samlのexampleのコピペだったりするんですが、いくつか異なる点があります。

1つ目は、settings.protocol_binding属性にurn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST を指定している点です。

LINE WORKSのSAMLのドキュメントを読むと、

SAML Request に指定した Protocol Binding により、GET または POST を使用します。
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" の場合は POST、それ以外は GET を使用します。

出典: https://developers.worksmobile.com/jp/document/1001500302?lang=ja

との記載があります。

先ほど設定したconfig/routes.rbの定義を確認すると、

      post :acs

となっており、POSTリクエストで/acsが叩かれることを想定しているため、上記の設定を加えることでPOSTリクエストで叩かれるようにしています。(これにハマって1日溶かしました。ドキュメント読むの大事ですね。)

2つ目は、IdPセクションの設定値を環境変数にしていることですね。



これで準備完了!と言いたいところですが、肝心のLINE WORKSのアカウントがないので作成します。


LINE WORKS にアカウントを作成

LINE WORKSでSAML2.0によるSSO機能を使うには、有料アカウントで登録する必要があります。(2021年10月時点。ライトプラン・ユーザー一人当たり月額360円)

360円は自己投資だと思うようにしましょう(技術書よりは全然安いし、、、)

line.worksmobile.com

料金表はこちらです。

line.worksmobile.com


まずは無料でアカウントを作成し、その後にライトプランにアップグレードする必要があります。


SAML Appsの登録

無事課金し終えてライトプランにアップグレードできましたら、LINE WORKSのデベロッパーコンソールからSSO機能を利用するアプリの登録を行う必要があります。

f:id:kossy-web-engineer:20211017204035p:plain

・Application Nameにはsaml-sp

ACS URLには http://localhost:3000/saml/acs

・SP Issuer(Entity ID)には http://localhost:3000/saml/metadata

を入力してください。

f:id:kossy-web-engineer:20211017204155p:plain

「次へ」をクリックし、以下の表示が出れば設定成功です。

f:id:kossy-web-engineer:20211016211905p:plain
registered

登録後、以下URLにアクセスすると、

https://developers.worksmobile.com/jp/console/idp/saml/view

f:id:kossy-web-engineer:20211016212325p:plain
SAML Apps

先ほど追加したサービスが表示されていると思いますので、まずは使用状態を「無効」から「有効」に切り替えます。

「変更」をクリックすると、以下のモーダルが表示されますので、ラジオボタンの「有効」をクリックし、「保存」をクリックすると、使用状態を切り替えられます。

f:id:kossy-web-engineer:20211016212543p:plain

次に、「LINE WORKS Identity Provider情報」をクリックすると、SSO URL と Response Issuer の確認と Certificate のダウンロードが行えますので、

SSO URL と Response Issuer は一旦どこかにコピペしておき、 Certificateのダウンロードを行ってください。

f:id:kossy-web-engineer:20211016212856p:plain
Identity_provider_information

ダウンロードしたCertificateを使って、以下コマンドを叩いてフィンガープリントを取得してください。

$ openssl x509 -text -noout -in ~/Downloads/<your file name> -fingerprint -sha256

出力された値はおそらく以下のような感じになるはず。

SHA256 Fingerprint=59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM

この値を、application.ymlに追記してください。
ついでにentity_idやsso_target_urlで使う値も環境変数にします。

config/application.yml

IDP_CERT_FINGERPRINT: 59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM:59:10:LO:D0:9L:31:PO:AM
IDP_GROUP_NAME: your_group_name

これで準備完了です。動作を確認してみましょう。



localhost:3000にアクセス

f:id:kossy-web-engineer:20211017202816p:plain

LoginをクリックするとLINE WORKSのログイン画面にリダイレクトする

f:id:kossy-web-engineer:20211017202932p:plain

ログインに成功すると、ローカルのアプリにリダイレクトして、以下の画面が表示される

f:id:kossy-web-engineer:20211017203113p:plain

ログアウトも可能

f:id:kossy-web-engineer:20211017203139p:plain

Single Logoutの方は正しく動作しなかったので、いつか検証してブログにします。


まとめ

少々ハマりどころもありましたが、ruby-saml Gemのexampleを参考にすれば簡単に動作確認まで行うことができました。これが本番運用となるとまた考慮するポイントが変わってくるとは思いますが、

それは追々ブログにまとめたいと思います。(まずはherokuとかからかな)

あとはdevise_saml_authenticatable等のGemではどう実装すればいいか?などもありますので、まだまだSAMLについての記事を書いていくことになると思います。