SalesforceをOPにしてOpenID Connectによるソーシャルログインが出来るということなので試してみた。
せっかくなのでSalesforceユーザではなく、コミュニティユーザでソーシャルサインオンをやってみます。
RP側はPython/Flaskで。
1. 接続アプリケーションの設定
いつもの接続アプリケーション。今回はaccess_tokenを使ってUserInfo以外のAPIを叩かないのでid openid refresh_tokenをscopeに入れました。
2. RP側プログラムの作成
pythonのバージョンは3.3系。暗号化ライブラリとしてPyCryptoを使ってます。
# -*- coding: utf-8 -*-
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
from Crypto.Util.number import bytes_to_long
from flask import Flask, request, session, render_template
from urllib.error import HTTPError
import urllib.request
import base64
import json
import time
import datetime
import random
import hmac
BASE_URL = "https://{community domain}"
CLIENT_ID = "input your client_id"
CLIENT_SECRET = "input your client_secret"
REDIRECT_URI = "input your redirect_uri"
def get_random_string(length):
"""
ランダムな文字列を生成する
"""
source_str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
return "".join([random.choice(source_str) for x in range(length)])
def b64d(b):
"""
base64urlデコード
"""
b += "=" * ((4 - len(b)) % 4)
return base64.urlsafe_b64decode(b)
def b64e(b):
"""
base64urlエンコード
"""
return base64.urlsafe_b64encode(b).decode().replace("=", "")
def b64_to_long(b):
"""
base64エンコードした文字列をロング型に変換
"""
return bytes_to_long(b64d(b))
def get_rsa_publickey(jwt_header):
"""
https://{sfdcドメイン}/id/keysから公開鍵を取得する。
"""
kid = json.loads(b64d(jwt_header).decode())["kid"]
req = urllib.request.Request(url=BASE_URL + "/id/keys")
res = urllib.request.urlopen(req)
jsonMap = json.loads(res.read().decode())
target_key = {}
for key in jsonMap["keys"]:
if key["kid"] == kid:
target_key = key
break
modulus = b64_to_long(target_key["n"])
exponent = b64_to_long(target_key["e"])
return RSA.construct((modulus, exponent))
def validate_idtoken(jwt):
"""
JWSの署名検証
"""
jwt_array = jwt.split(".")
jws_payload = jwt_array[0] + "." + jwt_array[1]
#https://{sfdcドメイン}/id/keysから公開鍵の情報を取得
key = get_rsa_publickey(jwt_array[0])
#jws_payloadのSHA-256ハッシュ値と復号化した署名が一致しているかどうかを検証
signer = PKCS1_v1_5.new(key)
local_hash = SHA256.new(jws_payload.encode())
return signer.verify(local_hash, b64d(jwt_array[2]))
def create_signature(json_map):
"""
id,issued_atからSignatureを生成し署名を検証する
"""
payload = json_map["id"] + json_map["issued_at"]
signature = hmac.new(CLIENT_SECRET.encode(), payload.encode(), SHA256).digest()
return base64.b64encode(signature).decode()
def get_idtoken(code):
"""
id_token+access_tokenの取得
"""
#access_tokenの取得
token_req_param = {
"grant_type" : "authorization_code",
"client_id" : CLIENT_ID,
"client_secret" : CLIENT_SECRET,
"redirect_uri" : REDIRECT_URI,
"code" : code
}
req = urllib.request.Request(
url=BASE_URL + "/services/oauth2/token",
data=urllib.parse.urlencode(token_req_param).encode(),
headers={"Content-Type" : "application/x-www-form-urlencoded"}
)
try:
res = urllib.request.urlopen(req)
return json.loads(res.read().decode())
except HTTPError as error:
print(error.reason)
return {}
def get_userinfo():
"""
ユーザ情報の取得
"""
#access_tokenの取得
req = urllib.request.Request(
url=session["instance_url"] + "/services/oauth2/userinfo",
headers={"Authorization" : "Bearer " + session["access_token"]}
)
try:
res = urllib.request.urlopen(req)
return json.loads(res.read().decode())
except HTTPError as error:
print(error.reason)
return {}
app = Flask(__name__)
app.debug = True
app.secret_key = get_random_string(32)
@app.route("/")
def index():
"""
Authorization Requestの為の初期ページ
"""
request_param = {
"response_type" : "code",
"client_id" : CLIENT_ID,
"redirect_uri" : REDIRECT_URI,
"scope" : "id openid refresh_token",
"state" : get_random_string(32),
"nonce" : get_random_string(32)
}
session["state"] = request_param["state"]
session["nonce"] = request_param["nonce"]
authorization_url = BASE_URL + "/services/oauth2/authorize?" + urllib.parse.urlencode(request_param)
return render_template("index.html", authorization_url=authorization_url)
@app.route("/callback")
def callback():
"""
Authorization Requestに対するコールバックを受け取る
"""
#state値が正しいかどうか確認
if session["state"] != request.args.get("state"):
return "invalid parameter error!!"
#id_token(+access_token)の取得
json_map = get_idtoken(request.args.get("code"))
#signature検証
if json_map["signature"] != create_signature(json_map):
return "invalid parameter error!!"
#id_tokenの検証
if validate_idtoken(json_map["id_token"]):
jwt_array = json_map["id_token"].split(".")
jwt_claim = json.loads(b64d(jwt_array[1]).decode())
now = int(time.mktime(datetime.datetime.now().utctimetuple()))
exp = int(jwt_claim["exp"])
#token有効期限の確認
if now > exp:
return "code is expired!!"
if jwt_claim["nonce"] != session["nonce"]:
return "invalid parameter error!!"
signature = SHA256.new(json_map["refresh_token"].encode()).digest()
#signature = SHA256.new(json_map["access_token"].encode()).digest() #at_hash
#signature = SHA256.new(request.args.get("code").encode()).digest() #c_hash
print(json_map)
print(jwt_claim)
signature = b64e(signature[:int(len(signature)/2)]).replace("=", "")
#hash, issuer, audienceの確認
if signature == jwt_claim["c_hash"] and jwt_claim["iss"] == "https://login.salesforce.com" and jwt_claim["aud"] == CLIENT_ID:
session["id"] = jwt_claim["sub"]
session["access_token"] = json_map["access_token"]
session["instance_url"] = json_map["instance_url"]
userinfo = get_userinfo()
return "hello " + userinfo["name"] + "<br/>login is successful!!"
return "validation error!!"
if __name__ == "__main__":
app.run()
index.html
<!DOCTYPE html>
<html>
<head></head>
<body>
<div>
<a href="{{ authorization_url }}">Salesforceコミュニティでログイン</a>
</div>
</body>
</html>
最初は通常のOAuth2.0と同じ。
scopeはid openid refresh_tokenを指定。
idはuserinfo取得用、openidはid_token取得用、refresh_tokenはJWS検証用
※本当はcodeのハッシュを検証に利用するのですが、現時点ではrefresh_tokenのハッシュになっているっぽいです。なのでコードもrefresh_tokenになっていますが、issueが解決されたらcodeのハッシュを取るようにしてください。
CSRF対策にSessionと紐付いたstate値とnonce値を入力。
(nonceはAuthorization Code Flowでは入れる必要は無いです。Implicit Flowでは必須。)
callbackで戻ってきたら、state値が合っているかを検証。
合っていればOAuth2.0と同じくcode値を使ってid_token+access_tokenを取得します。
レスポンスは以下のようになります。
{
"scope": "id openid refresh_token",
"issued_at": "1404657905515",
"instance_url": "https://******.salesforce.com",
"id_token": "eyJhb*******.eyJle**************.DNyJ_E_*************",
"id": "https://login.salesforce.com/id/00D************/005***********",
"signature": "Ha2CB4t89xks6HQ3FCOOQQ1GzlZG5Pgpuok2513otMk=",
"token_type": "Bearer",
"access_token": "00DA0*********!AQoAQG2FfyQ**************************",
"refresh_token": "5Aep861Y************************************"
}
取得したid_tokenはJWT形式なのでピリオド区切りで順にヘッダ、クレームセット、署名部分にそれぞれ分解。
ヘッダとクレームセットはjsonをbase64エンコードしたものなのでデコードします。
ヘッダ↓
{
"alg": "RS256",
"typ": "JWT",
"kid": "188"
}
クレームセット↓
{
"nonce": "UoiCgJbKH1jzOZ3K4bdiwRRo4MYN5vJ2",
"iss": "https://login.salesforce.com",
"aud": "3MV****************************************",
"iat": 1404661954,
"c_hash": "mp4sCQW6O1GvqzpyBcXA7Q",
"sub": "https://login.salesforce.com/id/00D************/005***********",
"exp": 1404662074
}
クレームセット部分を使って現在のタイムスタンプ値とexp値を比較して期限が切れていないかとnonce値が合っているかをチェックします。
合っていればhttps://login.salesforce.com/id/keysに表示されている公開鍵情報からヘッダのkidに対応する鍵を取得します。
対象の公開鍵を使って署名検証を行いOKであれば初めて認証成功となります。
Salesforceは現時点で署名方式が”RS256”なので
Sha256Hash(JWTヘッダ+ “.” + JWTクレームセット) = RSADecrypt(JWS)
となればOK。
ちなみに、一応access_tokenのレスポンス内のsignature検証もしていてこちらは
Sha256(id値 + issued_at値) = signature値
となっていればOK。
上記の処理内でAuthorizatoin Code FlowでClient Secretが機密にされているという理由で
検証不要な項目もあると思いますが、全部やろうとするとこんな感じになります。
あと、ソースコードは各エンドポイントやらissuer値がベタ書きですが、本当は以下のDiscoveryEndpointの情報を参照します。
https://login.salesforce.com/.well-known/openid-configuration
{
"issuer": "https://login.salesforce.com",
"authorization_endpoint": "https://login.salesforce.com/services/oauth2/authorize",
"token_endpoint": "https://login.salesforce.com/services/oauth2/token",
"revocation_endpoint": "https://login.salesforce.com/services/oauth2/revoke",
"userinfo_endpoint": "https://login.salesforce.com/services/oauth2/userinfo",
"jwks_uri": "https://login.salesforce.com/id/keys",
"scopes_supported": [
"id",
"api",
"web",
"full",
"chatter_api",
"visualforce",
"refresh_token",
"openid",
"profile",
"email",
"address",
"phone",
"offline_access"
],
"response_types_supported": [
"code",
"token",
"token id_token"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"display_values_supported": [
"page",
"popup",
"touch"
],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"private_key_jwt"
]
}
3. 実際の動き
flaskアプリを起動してhttp://localhost:5000とかにアクセスすると認証用リンクが出るのでクリックします。
コミュニティ用のログイン画面が出るのでログインします。
いつもの認可画面
許可するとリダイレクトしてid_tokenとaccess_tokenをして検証やら何やらをしてOKであれば画面にユーザ名が出ます。
こんな感じで基本的にOAuth2.0に色々な検証が加わったものがOpenID Connectなので
OpenIDやSAMLよりかは設定や検証がだいぶ楽になってます。
個人的にハマったのは公開鍵部分ぐらいで、これも暗号化の仕組みわかってて慣れてる人からしたら
大したものじゃないと思います。
SFDCがOpenID ConnectのOPになれるということは既存アプリケーションと連携しやすくなるということで、
同業種の別の会社のWebサービスと自社のSFDCコミュニティがソーシャルサインオンで連携できるとか
そういうこともやりやすくなるなーと思ったり。