こんにちは!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というリンクがあるページが表示されます。
クリックすると以下の表示になるはずです。
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円は自己投資だと思うようにしましょう(技術書よりは全然安いし、、、)
料金表はこちらです。
まずは無料でアカウントを作成し、その後にライトプランにアップグレードする必要があります。
SAML Appsの登録
無事課金し終えてライトプランにアップグレードできましたら、LINE WORKSのデベロッパーコンソールからSSO機能を利用するアプリの登録を行う必要があります。
・Application Nameにはsaml-sp
・ACS URLには http://localhost:3000/saml/acs
・SP Issuer(Entity ID)には http://localhost:3000/saml/metadata
を入力してください。
「次へ」をクリックし、以下の表示が出れば設定成功です。
登録後、以下URLにアクセスすると、
https://developers.worksmobile.com/jp/console/idp/saml/view
先ほど追加したサービスが表示されていると思いますので、まずは使用状態を「無効」から「有効」に切り替えます。
「変更」をクリックすると、以下のモーダルが表示されますので、ラジオボタンの「有効」をクリックし、「保存」をクリックすると、使用状態を切り替えられます。
次に、「LINE WORKS Identity Provider情報」をクリックすると、SSO URL と Response Issuer の確認と Certificate のダウンロードが行えますので、
SSO URL と Response Issuer は一旦どこかにコピペしておき、 Certificateのダウンロードを行ってください。
ダウンロードした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にアクセス
LoginをクリックするとLINE WORKSのログイン画面にリダイレクトする
ログインに成功すると、ローカルのアプリにリダイレクトして、以下の画面が表示される
ログアウトも可能
Single Logoutの方は正しく動作しなかったので、いつか検証してブログにします。