Ruby on Rails(ROR) 에서 SAML IdP(Identity Provider) 구현하기(SSO)

최근에 뜻밖에 SAML 관련 공부를 하고 있는지라, 몇가지 내용 메모/공유할겸 포스팅 작성해봅니다.

오늘은 Ruby on Rails에서 SAML IdP(Identity Provider)를 구성하는 방법에 대해 이야기할까 합니다.

SAML(Security Assertion Markup Language)?

sso 인증 방식에는 대표적으로 cas, oauth, saml이 있습니다. cas는 쿠키 베이스로 상위 도메인이 동일한 경우에만 인증을 사용할 수 있고(당연한 이야기, 웹 정책 상 타 도메인 쿠키를 접근할 수가 없지요) oauth와 saml은 크로스 도메인간 인증이 가능한 방식입니다.

둘다 많이 쓰이는걸로 알고 있습니다. 자세히 설명드리기엔 시간이 늦은지라 아래 개념정도는 파악해두시고, 구글링해서 SAML에 대해서 더 찾아보시는게 좋을 것 같습니다.

SP(Service Provider)

  • 클라이언트가 접근하려는 서비스

IdP(Identity Provider)

  • 클라이언트의 접근 정보를 인증하고 SP에게 인증된 사용자라는 정보를 전달

Metadata

  • SSO를 활성화하는 SP 및 IdP 가 생성하는 XML 파일이며 메타데이타의 교환으로 SP와 IdP의 신뢰 관계게 형성됨
https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language

rails app 구성

우선 테스트에 사용할 레일즈 앱을 하나 만들어봅시다.

#> rails new idp_test

saml_idp 라이브러리를 쓰기 위해선 Gemfile에 추가해주시고 bundler로 설치해줍니다.

#> vim Gemfile
‘gem 'saml_idp' # 추가
#> bundle install

or

#> gem install saml_idp

그냥 gem으로 설치해도 무방합니다.

router.rb 설정

우선 router.rb를 수정해서 saml_idp 에서 제공하는 API 페이지로 데이터가 넘어가도록 라우팅을 잡아줍니다.

get '/saml/auth' => 'saml_idp#new'          # 인증
get '/saml/metadata' => 'saml_idp#show'     # 메타데이터 가져오기
post '/saml/auth' => 'saml_idp#create'      # 신규 인증정보 생성
match '/saml/logout' => 'saml_idp#logout', via: [:get, :post, :delete] #삭제

Controller 만들어주기

이제 실제 코드동작을 할 컨트롤러를 만들어줍니다. 이름은 saml_idp라고 지었습니다.

#> rails g  controller saml_idp

처리 로직을 추가합시다. 아 그리고 User는 미리 만들어야합니다. scaffold로 만들면 쉽습니다

#> vim app/controller/saml_idp_controller.rb
class SamlIdpController < SamlIdp::IdpController # 인증 정보가 날라왔을 때 User 데이터에서 데이터를 가져와 비교한 후 결과를 리턴해줍니다.
  def idp_authenticate(email, password) # not using params intentionally
    user = User.get_email(email).first
    user && user.valid_password?(password) ? user : nil
  end
  private :idp_authenticate

  def idp_make_saml_response(found_user) # saml_response 생성
    # NOTE encryption is optional
    encode_response found_user, encryption: {
      cert: saml_request.service_provider.cert,
      block_encryption: 'aes256-cbc',
      key_transport: 'rsa-oaep-mgf1p'
    }
  end
  private :idp_make_saml_response

  def idp_logout # 로그아웃 시
    user = User.by_email(saml_request.name_id)
    user.logout
  end
  private :idp_logout
end

Initalizers 작성

initializers를 통해서 Application 시작 시 동작을 지정할 수 있는데, 이 구간에서 saml 관련 세팅을 미리 잡아줄 수 있습니다. 우선 코드 작성 전 인증에 사용될 인증서를 하나 만들어줍니다.

#> openssl req -x509 -sha256 -nodes -days 3650 -newkey rsa:2048 -keyout myKey.key -out myCert.crt

key 파일과 crt 파일이 남는데, 각각 아래 코드단에서 cert, private key로 등록시켜줍니다.

#> vim ./config/initializers/saml_test.rb
SamlIdp.configure do |config|
  base = "your domain!!!zzz"

  config.x509_certificate = <<-CERT
-----BEGIN CERTIFICATE-----  # 해당 영역에 myCert.crt 인증서 삽입
CERTIFICATE DATA
-----END CERTIFICATE-----
CERT

  config.secret_key = <<-CERT
-----BEGIN RSA PRIVATE KEY-----  # myKey.key 파일(private key) 삽입
KEY DATA
-----END RSA PRIVATE KEY-----
CERT

  # config.password = "secret_key_password"  # [ + ] 매번 요청마다 콘솔에서 비밀번호를 물어보는데, config.password에 한번 설정해두면 질의없이 넘어갑니다.
  # config.algorithm = :sha256
  #config.organization_name = "Your Organization"
  #config.organization_url = "http://example.com"
  # config.base_saml_location = "#{base}/saml"
  # config.reference_id_generator                                 # Default: -> { UUID.generate }
  # config.attribute_service_location = "#{base}/saml/attributes"
  # config.single_service_post_location = "#{base}/saml/auth"
  # config.session_expiry = 86400                                 # Default: 0 which means never

  # Principal (e.g. User) is passed in when you `encode_response`
  #
  # config.name_id.formats # =>
  #   {                         # All 2.0
  #     email_address: -> (principal) { principal.email_address },
  #     transient: -> (principal) { principal.id },
  #     persistent: -> (p) { p.id },
  #   }
  #   OR
  #
  #   {
  #     "1.1" => {
  #       email_address: -> (principal) { principal.email_address },
  #     },
  #     "2.0" => {
  #       transient: -> (principal) { principal.email_address },
  #       persistent: -> (p) { p.id },
  #     },
  #   }

  # If Principal responds to a method called `asserted_attributes`
  # the return value of that method will be used in lieu of the
  # attributes defined here in the global space. This allows for
  # per-user attribute definitions.
  #
  # class User
  #   def asserted_attributes
  #     {
  #       phone: { getter: :phone },
  #       email: {
  #         getter: :email,
  #         name_format: Saml::XML::Namespaces::Formats::NameId::EMAIL_ADDRESS,
  #         name_id_format: Saml::XML::Namespaces::Formats::NameId::EMAIL_ADDRESS
  #       }
  #     }
  #   end
  # end
  #
  # If you have a method called `asserted_attributes` in your Principal class,
  # there is no need to define it here in the config.

  # config.attributes # =>
  #   {
  #     <friendly_name> => {                                                  # required (ex "eduPersonAffiliation")
  #       "name" => <attrname>                                                # required (ex "urn:oid:1.3.6.1.4.1.5923.1.1.1.1")
  #       "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", # not required
  #       "getter" => ->(principal) {                                         # not required
  #         principal.get_eduPersonAffiliation                                # If no "getter" defined, will try
  #       }                                                                   # `principal.eduPersonAffiliation`, or no values will
  #    }                                                                      # be output
  #
## EXAMPLE 
  # config.attributes = {
  #   GivenName: {
  #     getter: :first_name,
  #   },
  #   SurName: {
  #     getter: :last_name,
  #   },
  # }
## EXAMPLE 

  # config.technical_contact.company = "Example"
  # config.technical_contact.given_name = "Jonny"
  # config.technical_contact.sur_name = "Support"
  # config.technical_contact.telephone = "55555555555"
  # config.technical_contact.email_address = "example@example.com"

  service_providers = {
    "some-issuer-url.com/saml" => {
      fingerprint: "9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D",
      metadata_url: "http://some-issuer-url.com/saml/metadata"
    },
  }

  # `identifier` is the entity_id or issuer of the Service Provider,
  # settings is an IncomingMetadata object which has a to_h method that needs to be persisted
  config.service_provider.metadata_persister = ->(identifier, settings) {
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    File.open Rails.root.join("cache/saml/metadata/#{fname}"), "r+b" do |f|
      Marshal.dump settings.to_h, f
    end
  }

  # `identifier` is the entity_id or issuer of the Service Provider,
  # `service_provider` is a ServiceProvider object. Based on the `identifier` or the
  # `service_provider` you should return the settings.to_h from above
  config.service_provider.persisted_metadata_getter = ->(identifier, service_provider){
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    full_filename = Rails.root.join("cache/saml/metadata/#{fname}")
    if File.file?(full_filename)
      File.open full_filename, "rb" do |f|
        Marshal.load f
      end
    end
  }

  # Find ServiceProvider metadata_url and fingerprint based on our settings
  config.service_provider.finder = ->(issuer_or_entity_id) do
    service_providers[issuer_or_entity_id]
  end
end

위에 주석으로 달긴했지만 config.password에 키 만들때 들어간 패스워드를 설정해주면 매번 요청마다 콘솔에 비밀번호를 묻지 않게됩니다.

실행 및 접근

모든게 준비되었으면 웹을 돌려봅시다.

#> rails s

우선 테스트로 metadata에 접근해봅시다. 데이터가 잘 받아와진다면 정상 동작입니다 :)

http://127.0.0.1/saml/metadata

추가로 sp(service provider)에서 해당 rails app으로 saml 인증 요청을 날리게 되면 아래와 같이 서버가 잘 처리해줍니다.