🎉

Cognitoを試す...!

2022/05/05に公開

ユーザ認証周りを悩む

  • 今時ユーザ認証を手元でやりたくないですよね?(偏見)
  • 基本AWSをメインにしているので、AWSで完結したいですよね?(偏見)
  • cognito 使いたいって言われた(現実)

cognito 何それ美味しいの?というぐらいさっぱりわからないので、どんな感じなのか試してみる。
が、Railsを始めたばかりの初心者には厳しいので、GitHubに転がってたものを手元で動かしてみる方針

今回お世話になった example

https://github.com/mheffner/rails-cognito-example

先に まとめ その後に調べた内容

  • user を cognito の URL に User Pool を付与して認証画面に送っている
  • cognito 側で認証が完了したら callback 先に戻してもらう
    • その時に code もあわせてバックしてもらっている
  • バック時の code を利用して /oauth2/token に Rails 側から逆引きしている感じ
    • 正常に逆引き(return 200)された時にはOKとする
    • 200 の時には response 内容をJSON形式で保持
  • OIDC.token_id[:sub] が rails DB の user と cognito の user を一致させる値(subscribe id)
  • sessionに cognito_session_id を保持する事で login 状態としている
  • 言い換えると、 logout 時に cognito_session table から該当IDを削除し(に、利用している)、session を消し去る

手元環境準備

  • Forkして自分のGitHubへ持ってくる
  • postgresql だったので、好みの問題で MySQL 化
  • local で動かすために docker-compose 用意
  • ER図見たいなとか、 IDE Setting File とか、 .env 外しておかないととかで .gitignore 追記したり、 gem 追加したり
    変更点

あとはいつも通りDBまわりを

% rails db:create
Created database 'cognito_idp_development'
Created database 'cognito_idp_test'
% rails db:migrate
== 20190724144217 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0179s
== 20190724144217 CreateUsers: migrated (0.0180s) =============================

== 20190724144310 CreateCognitoSessions: migrating ============================
-- create_table(:cognito_sessions)
   -> 0.0176s
== 20190724144310 CreateCognitoSessions: migrated (0.0177s) ===================

== 20190724144814 AddSessionsTable: migrating =================================
-- create_table(:sessions)
   -> 0.0152s
-- add_index(:sessions, :session_id, {:unique=>true})
   -> 0.0357s
-- add_index(:sessions, :updated_at)
   -> 0.0152s
== 20190724144814 AddSessionsTable: migrated (0.0664s) ========================

% 

Congnito と .env ファイルを準備

  • 基本的には元の mheffner-san のキャプチャ通りに作成した

  • local なので コールバックURL だけ変更した感じです。

  • 上記作成後に Cognito の管理画面から必要な項目を抜粋

  • AWS_COGNITO_DOMAIN が、https:// ~~~ amazoncognito.com で入れてたらつまづいた。入力するサブドメイン部分だけでよかった

    • カスタムドメインも利用できるが、今回は使ってない
#.env
AWS_COGNITO_APP_CLIENT_ID=
AWS_COGNITO_APP_CLIENT_SECRET=
AWS_COGNITO_DOMAIN=
AWS_COGNITO_POOL_ID=
AWS_COGNITO_REGION=

動かしてみた

  • http://localhost:3000/

  • sign_up

    • 今回は Congnito 側のUIを利用した
    • パスワード強度なども Cognito で設定した通りとなる。
      • 便利ですね、社内ツールでは十分では!?
  • verify

    • cognito 側で用意されている SES を今回は利用した。
    • 自分で作った SES ではない。(UI的に指定はできる様子)
    • 届いたメール
  • sign_up done

色々追いかけてみよう

  • そもそも Fork してちょこちょこ変更した事以外には眺めてなかったので動作を追いかけてみたいと思う。

通信の全体像

  • 多分こんな感じ
  • 開発者ツール(Network DOCのみ)

DBの中身を確認

mysql> show tables;
+-----------------------------------+
| Tables_in_cognito_idp_development |
+-----------------------------------+
| ar_internal_metadata              |
| cognito_sessions                  |
| schema_migrations                 |
| sessions                          |
| users                             |
+-----------------------------------+
5 rows in set (0.00 sec)
mysql> select * from cognito_sessions \G
*************************** 1. row ***************************
           id: 1
      user_id: 1
  expire_time: 1651725260
  issued_time: 1651721660
     audience: 4v47480mtu084j2fmr5ppsosjm
refresh_token: eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.BDA1KSH8Mvhh5O0qhhP7B9m6JRw5Ih_fC57ScuFHreTa2MQovsAmxk6Y1M4XnezriM56A0NMJ_sUAty-s6CIcZIz8kBzTkIhqSNaPj_S9IOiWiQvIggleyMK4WKoCFldFj8lZxUMFShlveLD3zwWotw5llNQCAFdrrWOZmDUMMYDUMMYDUMMYDUMMYDUMMY-BAj3YPxc3nS1wqEWx-NOci_HmYzVTlGeBMKXfDNWE18eQsDs03WuhCFnk8UjFZGTJ2pm7mQwqAnI3TDfaF5WuvaiuC10PdMTej73Bj7tfB7-ZhSc4KHrIvZvnN9CEHh26Kjg_5vII7mXQwuEhdjPIqc-WouJF.UD88aGEgRJtvOMIpjAHCvw
   created_at: 2022-05-05 03:34:20
   updated_at: 2022-05-05 03:34:20
1 row in set (0.01 sec)
mysql> select * from users \G
*************************** 1. row ***************************
        id: 1
subscriber: 96646a89-154c-4bce-a5ce-26bd79dd0b03
     email: s************@gmail.com
created_at: 2022-05-05 03:34:20
updated_at: 2022-05-05 03:34:20
1 row in set (0.00 sec)
mysql> select * from sessions \G
*************************** 1. row ***************************
        id: 1
session_id: 2::600226c83f5ded72290938bfdfe48e967703b2604f7c3f93b03f4f5d57bc307d
      data: {"value":{"_csrf_token":"pFWKy9v7Bbl7d9jNMaI4xlqHrbL1WemVWTbdCzRhAE4=","cognito_session_id":1}}
created_at: 2022-05-05 03:28:32
updated_at: 2022-05-05 03:34:20
1 row in set (0.00 sec)
  • ER図

  • Cognito側を見てみると登録がされている

Cognito へのリクエスト内容を確認する

  • config/initializers/cognito.rb
if !ENV['AWS_COGNITO_DOMAIN'].blank?
  CognitoUrls.init(ENV['AWS_COGNITO_DOMAIN'],
                   ENV['AWS_COGNITO_REGION'])

  CognitoJwtKeysProvider.init(ENV['AWS_COGNITO_POOL_ID'])
else
  puts "Skipping Cognito initialization"
end
  • config / routes.rb (sign_up まわりのみ抜粋)
get '/sign_up', as: 'signup', to: 'sessions#signup'
  • app / controllers / sessions_controller.rb (sign_up まわりのみ抜粋)
  def signup
    redirect_to cognito_signup_url
  end
  
private

  def cognito_signup_url
    CognitoUrls.signup_uri(ENV['AWS_COGNITO_APP_CLIENT_ID'],
                           signin_redirect_uri)
  end
  
  def signin_redirect_uri
    auth_sign_in_url
  end
  
# 補足の routing 情報
auth_sign_in GET  /auth/sign_in(.:format)                                                                  auth#signin
  • lib / cognito_urls.rb (sign_up まわりのみ抜粋)
  SIGNUP_PATH = "/signup"

    def init(domain, region)
      @base_oauth_uri = "https://%s.auth.%s.amazoncognito.com" % [domain, region]
      @base_idp_uri = "https://cognito-idp.%s.amazonaws.com" % [region]
    end
    
    def signup_uri(app_client_id, redirect_uri)
      path = "%s?response_type=code&client_id=%s&redirect_uri=%s" %
        [SIGNUP_PATH, app_client_id, redirect_uri]
      URI.join(@base_oauth_uri, path).to_s
    end

最終的に組み立てられた内容(全体像の 3.redirect のところの指定URL)

  • redirect_uriって cognito の画面で設定した気がするんだが...いるのかな?(不明)
    https://${指定サブドメ}.auth.${REGION}.amazoncognito.com/signup?response_type=code&client_id=${COGNITO_CLIENT_ID}&redirect_uri=${CALLBACK受け取りPATH}

Cognito 認証OK後の CALLBACK への通知内容を確認する

  • app / controllers / auth_controller.rb (sign_in / _up まわりのみ抜粋)

    • code paramがついた状態で callback url にリダイレクトされてくる
      • /auth/sign_in?code=c0839475-b625-430d-a368-08413bd240d6
  • app / controller / auth_controller.rb

    • リターンがないと落とすとな。
  def signin
    unless params[:code]
      render :nothing => true, :status => :bad_request
      return
    end
  • app / controller / auth_controller.rb
    • code の内容確認
  # 上記 def signin の続き
    resp = lookup_auth_code(params[:code])
    unless resp
      redirect_to '/'
      return
    end
    
    client = new_cognito_client()
    client.get_pool_tokens(code)
  end
  • app / application_controller.rb
  def new_cognito_client
    CognitoClient.new(:redirect_uri => auth_sign_in_url)
  end
  • lib / cognito_client.rb
  def initialize(params = {})
    @pool_id = params[:pool_id] || ENV['AWS_COGNITO_POOL_ID']
    @client_id = params[:client_id] || ENV['AWS_COGNITO_APP_CLIENT_ID']
    @client_secret = params[:client_secret] || ENV['AWS_COGNITO_APP_CLIENT_SECRET']
    @redirect_uri = params[:redirect_uri]
  end
  
  def get_pool_tokens(authorization_code)
    params = {
      grant_type: 'authorization_code',
      code: authorization_code,
      client_id: @client_id,
      redirect_uri: @redirect_uri
    }

    resp = Excon.post(token_uri,
                      :user => @client_id,
                      :password => @client_secret,
                      :body => URI.encode_www_form(params),
                      :headers => { "Content-Type" => "application/x-www-form-urlencoded"})

    unless resp.status == 200
      Rails.logger.warn("Invalid code: #{authorization_code}: #{resp.body}")
      return nil
    end

    CognitoPoolTokens.new(CognitoJwtKeysProvider.keys, JSON.parse(resp.body))
  end
  • 補足: token_uri (token確認先)
TOKEN_PATH = "/oauth2/token"

    def token_uri
      URI.join(@base_oauth_uri, TOKEN_PATH).to_s
    end
  • リダイレクト時の code が正しい時
class CognitoPoolTokens
  def initialize(cognito_jwt_keys, token_hash)
    @cognito_jwt_keys = cognito_jwt_keys
    @token_hash = token_hash
    @token_cache = {}
  end
  • cognito から戻ってきた subscriber を 基にDB からユーザを特定している
  • 存在しない場合には sign_up として create している
  • そのまま認証通ってる扱いなので、 sign_in 状態のための cognito_session も create している
  • session に cognito_session.id を持たせている(cognito_session table の id カラムの値)
    ActiveRecord::Base.transaction do
      puts "resp.id_token" # 確認追加
      pp resp.id_token # 確認追加
      user = User.where(subscriber: resp.id_token[:sub]).first
      if user.nil?
        user = User.create(subscriber: resp.id_token[:sub],
                           email: resp.id_token[:email])
      end

      cognito_session = CognitoSession.create(user: user,
                                              expire_time: resp.id_token[:exp],
                                              issued_time: resp.id_token[:auth_time],
                                              audience: resp.id_token[:aud],
                                              refresh_token: resp.refresh_token)
      session[:cognito_session_id] = cognito_session.id
    end

    # Alternatively, you could redirect to a saved URL
    redirect_to '/'
  end
  

  def lookup_auth_code(code)
    client = new_cognito_client()
    client.get_pool_tokens(code)
  end
resp.id_token
{"at_hash"=>"6oRz8CfvZroYFuWxBscYLA",
 "sub"=>"35a050c7-ffc7-47f1-87c4-153942688853",
 "email_verified"=>true,
 "iss"=>"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_TNcJzOSgk",
 "cognito:username"=>"nyasu",
 "origin_jti"=>"d9c5f89b-83b5-4f3b-8d97-e49d15d41694",
 "aud"=>"4v47480mtu084j2fmr5ppsosjm",
 "event_id"=>"a4538fa5-dc0a-41a8-8c8c-7f7c0eb3cc99",
 "token_use"=>"id",
 "auth_time"=>1651751231,
 "exp"=>1651754831,
 "iat"=>1651751231,
 "jti"=>"edcb7a7a-6bde-4804-a27b-26a6dcd28473",
 "email"=>"s********@gmail.com"}
  • sub 値を取得している

SignOut

  • app / auth_controller.rb dev
  def signout
    if cognito_session_id = session[:cognito_session_id]
      cognito_session = CognitoSession.find(cognito_session_id) rescue nil
      cognito_session.destroy if cognito_session
      session.delete(:cognito_session_id)
    end

loing 状況check

  • については app / applocation_controller.rb でやってた
class ApplicationController < ActionController::Base
  before_action :check_signed_in

  def check_signed_in
    @is_signed_in = false
    @current_user = nil
    @cognito_session = nil

    cognito_session = nil
    if session[:cognito_session_id]
      begin
        cognito_session = CognitoSession.find(session[:cognito_session_id])
      rescue ActiveRecord::RecordNotFound
      end
    end

    unless cognito_session
      return
    end

    now = Time.now.tv_sec

    if cognito_session.expire_time > now
      # Still valid, use
      #

      Rails.logger.info("Found a non-expired cognito session: #{cognito_session.id}")
      @is_signed_in = true
      @current_user = cognito_session.user
      @cognito_session = cognito_session
      return
    end

    Rails.logger.info("Refreshing cognito session: #{cognito_session.id}")

    # Need to refresh token
    if refresh_cognito_session(cognito_session)
      @is_signed_in = true
      @current_user = cognito_session.user
      @cognito_session = cognito_session
      return
    end
  end

参考

備考

  • cognito が準備している UI についても多少はいじれる様子。

  • なんだけども、英語のみだったりするようで、細かいことをしたい場合には独自に UI を準備する必要があるとのこと。

  • 指定できる項目も多く見えた(他しらんけど)、SMS認証、メール送信なんかもこだわりがなければちょちょいっと使えそうでした。

  • その他の google 認証とか外部のIDプロパイダーも使えるようなのでちょっと試してみたいお気持ち

  • cognito 画面で user 削除すると rails db の users と整合性が取れなくなる。

  • cognito 画面で user 追加の場合には rails db 側で create するようにしておけば良いが...初回ログインがあるまではズレる

    • いや、まぁ、両方ともそもそもその状況になるのが... とは思うが何があるかわからないこの頃。
    • cognito user インポートはあるがエクスポートがないのでbatch処理的に吐き出しておいた方が良いのだろうか?
    • IDaaS 側の障害とか、復旧とかわからないな

感想

  • 便利そう、社内ツールならすいっと使ってもいいのではないだろうか。
  • でも、もうちょっと踏み込んでMFA周りとかも今後やってみたい
  • lib で実装されていて追いやすかった(気がする)
  • これで一回挫折した omniauth-cognito-idp の gem を使ってみる事ができるかもしれないな
  • cognito の通信内容を追いかけようと思ったのに Fork した mheffner-san のコードを追いかけてたり迷走気味でした。

Discussion