こんにちは。SARAHの沈(シム)です。
最近認証基盤をRailsのdeviseからAuth0への切り替えを行う作業があり、Auth0と既存のDBを繋げる機会がありまして、
自分が学んだ内用を共有しようかと思います。
目的
- RailsのDevise認証からAuth0への認証基盤を移行
- DBのユーザーデータは維持する(Export/Importなし)
- Auth0のOpen ID Connect(以下OIDC)を付けてAPI Gatewayを保護
Auth0へ移行した理由
弊社のFoodDataBankサービスがRuby on Railsで構築されましたが、これから追加する機能を考えた時に拡張性が低く、開発パフォーマンスもあまり出ない状況であるため、フロント側を新たに構築する流れになりました。RailsのAPIを立ち上げるとDeviseとの通信で認証はできますが、メンテナンス、コストなど色々考えたところ、Railsに載せるより、Auth0を用いて進むのがフロント側の認証繋ぎの負担も下がるし、将来的にEC2で立ち上げたRailsもなくなることによってコスト削減もできると見えました。また、Auth0はいろんな言語のサポートもでき、弊社のフロント側のフレームワークにも問題なく載せますので、Auth0への乗り換えを決めました。
説明
Devise、OIDC、Auth0の説明に関しては以下のリンクを参考してください。
・Ruby on Rails Devise
https://pikawaka.com/rails/devise
・OIDC
https://qiita.com/TakahikoKawasaki/items/498ca08bbfcc341691fe
・Auth0
https://auth0.com/docs
システム構成図
RailsのDeviseからAuth0への切り替え
RailsにAuth0を導入するのは難しくないかと思います。
Auth0のSampleコードをそのまま従って実装すれば簡単にできます。
https://auth0.com/docs/quickstart/webapp/rails/01-login
Auth0のDatabase作成
先にAuth0側にデータベースを作成します。
左のメニューから「Authentication->Database」をクリックし、「Create DB Connection」をクリックします。
「Name」の入力欄に今回作成するデータベースの名前を入力し、画面の下の「Create」ボタンを押下するとAuth0のデータベースが作成されます。
Auth0のApplication作成
次はAuth0側で認証するアプリケーションを登録します。左のメニューで「Applications->Applications」をクリックし、「Create Application」ボタンを押下します。
スクショのようなポップアップが表示されと、「Name」に今回作成するアプリケーションの名前を入力し、「Regular Web AppliCations」を選択、「Create」ボタンを押下します。
アプリケーションが作成され、「Setting」タブをクリックするとスクショのような画面が出てきます。
「Domain, Client ID, ClientSecret」をRails側で設定するものなのでAuth0のSampleコードを参照しながら設定してください。
次「Conncections」タブをクリックして先作成したAuth0のDatabaseを選択します。他のものは選択を外しても大丈夫です。
API GatewayとLambda
API GatewayにAuth0のOIDCを付けるにはクラスメソッドさんのブログを見ると理解できると思いますが、作る順番については少し異なるケースもあります。
https://dev.classmethod.jp/articles/amazon-api-gateway-http-api-authz-auth0/
API Gatewayを作成する前に適当なレスポンスができるLambdaを作成した後、API Gatewayを作るタイミングでLambdaと繋ぎます。(API GatewayはHTTP APIを選択しました。)
その後、API Gatewayの「認可」メニューをクリックし、OIDCを付けるパスとMethodを選択、「オーソライザーを作成してアタッチ」をクリックします。
オーソライざーのタイプは「JWT」を選択し、各項目は以下のように入力します。
- 名前:OIDCの名前を入力します。
- IDソース:$request.header.Authorization
- 発行者URL:Auth0のApplicationのDomainを入力します。(URLの最後に/は必ず入れてください)
- 対象者:API GatewayのURLを入力してください。
→例)https://zxxxxxx.execute-api.ap-northeast-1.amazonaws.com- API Gatewayのアップデートやリソース変更により、URLが頻繁に変わるそうな場合はAPI GatewayをRoute53に設定して固定化しましょう。
Lambdaの作成
API Gatewayと繋げるために適当に作成したLambda Functionのコードを修正します。
python 3.7ベースになっております。
※本記事ではproxy connectionは使わないです。
※Secrets Managerを用いて必要な環境変数データを取得しています。
- ロジック説明
入力されたユーザーのemail, passwordを受け取り、DB内に格納されているemailを検索します。
DB内のパスワードは暗号化されているので、ユーザーから入力されたパスワードを同じ規格で暗号化し、DB内のパスワードと比較します。
一致するパスワードであれば、ユーザー情報を返し、一致しない場合、エラーコードを返します。
※RailsのDeviseだとbcryptで暗号化されますので、ユーザーから入力したパスワードを同じ規格で暗号化すれば一致する暗号化されたパスワードが生成されます。
"""
Autorization for Auth0
"""
import os
import json
import sys
import logging
import base64
import urllib.request
import pymysql
import bcrypt
import boto3
from botocore.exceptions import ClientError
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Get Secret Manager ARN
secret_arn = os.environ.get('SECRETS_ARN','')
# Content-type設定
CONTENT_TYPE = "application/json"
def get_apikey():
"""get_apikey
AWS Secrets ManagerからAPIキーを取得
"""
session = boto3.session.Session()
client = session.client(
service_name = 'secretsmanager',
region_name = 'ap-northeast-1'
)
try:
get_secret_value_response = client.get_secret_value(SecretId=secret_arn)
except ClientError as err:
logger.info("Get Secret key error")
raise err
else:
if 'SecretString' in get_secret_value_response:
secret = get_secret_value_response['SecretString']
else:
secret = base64.b64decode(get_secret_value_response['SecretBinary'])
return secret
def lambda_handler(event, context):
"""lambda_handler
Lambda Handler
"""
secret_keys = json.loads(get_apikey())
logger.info(event)
if 'body' not in event :
return {
"statusCode": 401,
"headers": {
"Content-Type": CONTENT_TYPE
},
"body": None
}
#rds settings
db_host = secret_keys['DB_HOST']
db_user_name = secret_keys['DB_USERNAME']
db_password = secret_keys['DB_PASSWORD']
db_name = secret_keys['DB_DATABASE']
db_port = int(secret_keys['DB_PORT'])
# URLクエリパラメータを配列形式に変更
login_info = urllib.parse.parse_qs(base64.b64decode(event['body']).decode())
logger.info("Login User : {login_info['username'][0]}")
# ユーザーデータ取得
data = get_userdata_from_db(
db_host, db_user_name, db_password, db_name, db_port, login_info['username'][0]
)
logger.info("SUCCESS: Get User Data %s", data)
# ユーザーパスワードチェックし、レスポンスを返す
return get_user_authentication(
login_info['password'][0].encode('utf-8'),
data['encrypted_password'].encode('utf-8'), data
)
def get_userdata_from_db(
db_host,
db_user_name,
db_password,
db_name,
db_port,
user_id
):
"""get_userdata_from_db
DBからユーザーデータ取得
"""
# DB Access
try:
conn = pymysql.connect(
host=db_host,
user=db_user_name,
passwd=db_password,
db=db_name,
port=db_port,
connect_timeout=5
)
except pymysql.Error as err:
logger.error("ERROR: Unexpected error: Could not connect to MySql instance.")
logger.error(err)
sys.exit()
logger.info("SUCCESS: Connection to RDS mysql instance succeeded")
# Get User Data
try:
cursor = conn.cursor(pymysql.cursors.DictCursor)
# 取得するユーザーデータは環境に合わせて修正してください。
cursor.execute(
"""select t1.id as user_id, t1.email, t1.encrypted_password, t1.sign_in_count,
t1.current_sign_in_at, t1.last_sign_in_at, t1.current_sign_in_ip,
t1.client_id, t1.name, t1.admin_memo, t1.aasm_state, t1.created_at, t1.updated_at,
from users t1
where t1.email = %s""",
(user_id)
)
data = cursor.fetchone()
except pymysql.Error as err:
logger.error("ERROR: Unexpected error: Could not get data from MySql instance.")
logger.error(err)
sys.exit()
return data
def get_user_authentication(input_user_pass, db_user_pass, user_data):
"""get_user_authentication
User Password検査及びレスポンス
"""
# Match Password
if bcrypt.hashpw(input_user_pass, db_user_pass) == db_user_pass:
logger.info("User matches")
return {
"statusCode": 200,
"headers": {
"Content-Type": CONTENT_TYPE
},
"body": json.dumps(user_data, default=str)
}
# Unmatch Password
logger.info("It does not match")
return {
"statusCode": 401,
"headers": {
"Content-Type": CONTENT_TYPE
},
"body": None
}
※以下はAPI GatewayとLambdaが繋がってない場合、実施してください。既に繋がっている場合はスキップで大丈夫です。
Lambda関数のコードを修正しましたら、API GatewayとLambda functionを繋げます。
API Gatewayに入り、「統合」メニューでPOSTを選択し、「ルートの統合」を先作成したLambda functionで設定します。
Auth0のAPI作成
Auth0のconsoleに戻り、左の「Applications」下にある「APIs」メニューを選択し、「Create API」ボタンをクリックします。
以下のようなポップアップが表示され、「Name」には任意のものを入力し、「Identifier」にはAPI Gatewayの認可で入力した「対象者」で入力したものと同様に入力してください。
このように設定するとAPI GatewayはAuth0のOIDC設定され、Auth0から発行されたトークをヘッダに設定しないとAPI Gatewayはと取らない仕組みになります。
Auth0のDatabaseコード作成
Auth0のconsoleに戻り、左メニューから「Authentication」の「database」を選択し、「Custom Database」タブに移動します。
次、「Load Template」を選択し、「MySQL」テンプレートを選択します。
template中身を以下のコードで修正します。
function login(email, password, callback) {
const request = require('request');
const request_token = require('request');
var access_token = '';
// Applications->APIs->該当環境のAPI->「Test」タブで記入されている「body」の内容を貼り付け
var options = { method: 'POST',
url: '[Auth0のAPI URIを入力]',
headers: { 'content-type': 'application/json' },
body: '{"client_id":"[xxxxxxxxxxxxx]","client_secret":"[xxxxxxxxxxx]","audience":"[Auth0のAPIsで作成したIdentifierを入力]","grant_type":"client_credentials"}' };
request_token(options, function (error, response, body) {
if (error) throw new Error(error);
const obj = JSON.parse(body);
access_token = obj.access_token;
// 環境に合わせてAPI GatewayのURIを書き換える
request.post({
url: 'https://[API Gateway URIを入力]/login',
headers: {
// APIのAccess Token
"Authorization": "Bearer " + access_token
},
form: {
"username":email,
"password":password
}
//for more options check:
//https://github.com/mikeal/request#requestoptions-callback
}, function(err, mysql_response, mysql_body) {
if (err) return callback(err);
if (mysql_response.statusCode === 401) return callback();
const user = JSON.parse(mysql_body);
// Auth0のDatabaseに格納するユーザーデータ構造体
// 取得するデータは環境に合わせて修正してください。
callback(null, {
user_id: user.user_id,
email: user.email,
name: user.name,
user_metadata : {
user_active_status: user.aasm_state,
}
});
});
});
}
「保存」ボタンを押下し、「Save and Try」ボタンを押すとAPI Gateway/Lambda/MySQLとの通信確認ができます。
通信の確認ができたら「Universal Login」でもログインが可能になります。
終わりに
今回Auth0へ移行する時に参考になる資料がほとんど英語でしたので、なるべく早めに日本語の資料を増やそうと思って重要なものだけ書いてみました。(英語が得意な方は特に問題ないと思いますが。。)
弊社と同様に既存のデータを移行せず、使いたい方はこのようなやり方もありますので、ご参考になれば嬉しいです。
また、このようにデータを移行しないやり方もありますが、Auth0ではデータをImportする機能がありますので、パスワードの暗号化規格サポートができれば、Importする形でも結構簡単にできるかと思います。
各自の環境や状況に合わせてデータをインポートするか、既存のDBと繋げるかを決めて進めば良いかと思います。
告知
株式会社SARAHではエンジニアを募集中です!
ぜにこちらの記事を参考して頂ければ弊社の雰囲気は読めるかと思います。
- SARAH父ちゃんエンジニアの一日
ー 入社1年目のエンジニアが感じるSARAH
- SARAHエンジニアのTech Blog Hub
参考