Cognito+Lambdaを使ってユーザ認証をサーバーサイド側で処理してみた
はじめに
こんにちは。コンサルティング部の津谷です。
今回はWebアプリなどのユーザ認証基盤として利用するCognitoを検証したいと思います。
というのも、下記を勉強する素材としてCognitoは最適だと思ったためです。
・ブラウザからアプリケーションにアクセスするときのユーザ認証の仕組みを知りたい
・サーバサイド側でリクエストを受けて認証をマネージドサービスに依頼する場合はどのようなロジックになっているか知りたい
・シンプルにPythonの勉強もかねてバックエンド側で処理を記載したい
今回は、ユーザ認証をCognito+Lambdaでやってみたいと思います。
アプリと言っても認証の可否をメインで確かめたいので、ブラウザで表示するHTMLは簡素なものを作成し、フォームへの入力結果に応じて処理の結果をレスポンスとして返すといった程度に留めます。
サインアップ・メール認証(確認コードの入力)・ログイン作業はお決まりかと思いますので、リクエストパスに応じてバックエンド側からCognitoに送る処理を分岐させていきます。パスベースでRESTAPIを処理するためにブラウザとバックエンドLambdaの間にAPIGatewayを挟んで、ロジックを組んでいきます。
構成図
構成は以下になります。
ブラウザで表示するコンテンツはローカル環境で作成した「index.html」を用います。コンテンツ上でメールアドレス・パスワード・メール認証コードのフォーム入力を行い、処理の種別(サインアップ、メール認証、ログイン)によって適切なAPIGatewayのリソースパスへ遷移させます。画面は下記のような感じです。
APIGatewayに流れたリクエストに対してLambdaプロキシ統合を有効化します。リクエスト情報の詳細をAPIGatewayからLambdaにそのまま渡します。APIGatewayから送られてきたイベントに応じて関数が起動し、関数内の処理でCognitoへAPI接続することで、ユーザプールに登録します。ちなみに、Cognitoにはマネージドの機能でログイン(サインイン)設定、サインアップ設定が組み込まれているのでこの辺はだいぶ楽です。例えばCognitoから自動で対象のドメインに確認メールを飛ばしてくれたり、パスワード作成時にポリシー準拠しないとエラーを起こしてくれるといったこともしてくれます。
Cognitoの詳細な機能については、こちらをご参照ください。
構築手順
①Cognitoをセットアップしていきましょう。まずはユーザプールを作成します。
アプリケーションタイプは「シングルページアプリケーション (SPA)」を指定しましょう。
注意ポイントとして、バックエンドはLambdaですが「従来のアプリケーション」を選択してしまうと、クライアントシークレットが自動で発行されてしまいます。今回は検証なので利用しません。
公式ドキュメントがわかりやすいですので、こちらもご参照ください。アプリケーションクライアントの名前は「test-cognito-pool」を指定します。サインインの際に必要な情報は「メールアドレス」だけにします。(記載されていませんがパスワードも必須です)
サインアップの際に送信する情報は特に追加必要ありません。「ユーザ名」のみをサインインの情報にする際は別途「メールアドレス」や「電話番号」の属性を追加する必要があります。
ユーザプールの作成が完了したら、任意で名前をわかりやすくしておきましょう。
今回は「test-cognito-pool」としておきます。
設定したら、反映内容を確認してみましょう。
サインインの際は、デフォルトでメールアドレスとパスワードを用いて行う設定になっています。
サインアップの際はメール認証を行うので、そちらの設定も確認しておきましょう。
Cognito側でサインアップ時に入力したメールアドレス宛にサインインコード(6桁の番号)を送ってくれます。
パスワードも入力する必要がありますがその際のポリシーも編集することができます。今回はデフォルト設定のまま利用してみようと思います。
②次にサーバーサイド処理を行うLambdaをセットアップしていきましょう。
関数を下記のように作成してみます。
関数名は「test-lambda-request-to-cognito」にします。実行ロールは既存で作成した「test-lambda」を利用します。権限はCognitoAPIへの権限があればいいので下記をセットします。
・AmazonCognitoPowerUser
次にコードを記載しましょう。今回は下記のように処理を記載しました。
import json
import boto3
import urllib.parse
import os
from botocore.exceptions import ClientError
def lambda_handler(event, context):
# 環境変数から設定を取得
USER_POOL_ID = os.environ.get('USER_POOL_ID')
CLIENT_ID = os.environ.get('CLIENT_ID')
# 環境変数が設定されていない場合のエラーハンドリング
if not USER_POOL_ID or not CLIENT_ID:
return {
'statusCode': 500,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': '<h1>設定エラー: 環境変数が設定されていません</h1>'
}
# Cognitoクライアント初期化
cognito_client = boto3.client('cognito-idp', region_name='ap-northeast-1')
# URLパスで処理を判定
path = event.get('path', '')
if '/signup' in path:
return handle_signup(event, cognito_client, CLIENT_ID)
elif '/confirm' in path:
return handle_confirm(event, cognito_client, CLIENT_ID)
elif '/login' in path:
return handle_login(event, cognito_client, USER_POOL_ID, CLIENT_ID)
else:
return {
'statusCode': 404,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': '<h1>ページが見つかりません</h1>'
}
def handle_signup(event, cognito_client, client_id):
try:
# フォームデータを取得
body = urllib.parse.parse_qs(event['body'])
email = body.get('email', [''])[0]
password = body.get('password', [''])[0]
# Cognitoにユーザー登録
response = cognito_client.sign_up(
ClientId=client_id,
Username=email,
Password=password,
UserAttributes=[{'Name': 'email', 'Value': email}]
)
return {
'statusCode': 200,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': f'''
<!DOCTYPE html>
<html>
<head><title>登録結果</title></head>
<body>
<h1>登録成功</h1>
<p>メール: {email}</p>
<p>確認メールを送信しました</p>
<a href="/">戻る</a>
</body>
</html>
'''
}
except ClientError as e:
return {
'statusCode': 400,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': f'''
<!DOCTYPE html>
<html>
<head><title>登録エラー</title></head>
<body>
<h1>登録失敗</h1>
<p>エラー: {e.response['Error']['Message']}</p>
<a href="/">戻る</a>
</body>
</html>
'''
}
def handle_confirm(event, cognito_client, client_id):
try:
body = urllib.parse.parse_qs(event['body'])
email = body.get('email', [''])[0]
code = body.get('code', [''])[0]
response = cognito_client.confirm_sign_up(
ClientId=client_id,
Username=email,
ConfirmationCode=code
)
return {
'statusCode': 200,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': f'''
<!DOCTYPE html>
<html>
<head><title>確認完了</title></head>
<body>
<h1>メール確認完了</h1>
<p>メール: {email}</p>
<p>ログインできます</p>
<a href="/">戻る</a>
</body>
</html>
'''
}
except ClientError as e:
return {
'statusCode': 400,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': f'''
<!DOCTYPE html>
<html>
<head><title>確認エラー</title></head>
<body>
<h1>確認失敗</h1>
<p>エラー: {e.response['Error']['Message']}</p>
<a href="/">戻る</a>
</body>
</html>
'''
}
def handle_login(event, cognito_client, user_pool_id, client_id):
try:
body = urllib.parse.parse_qs(event['body'])
email = body.get('email', [''])[0]
password = body.get('password', [''])[0]
# ログイン処理(サーバー側認証)
response = cognito_client.admin_initiate_auth(
UserPoolId=user_pool_id,
ClientId=client_id,
AuthFlow='ADMIN_NO_SRP_AUTH',
AuthParameters={
'USERNAME': email,
'PASSWORD': password
}
)
# ユーザー情報取得
access_token = response['AuthenticationResult']['AccessToken']
user_response = cognito_client.get_user(AccessToken=access_token)
user_info = {}
for attr in user_response['UserAttributes']:
user_info[attr['Name']] = attr['Value']
return {
'statusCode': 200,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': f'''
<!DOCTYPE html>
<html>
<head><title>ログイン成功</title></head>
<body>
<h1>ログイン成功</h1>
<h2>ユーザー情報:</h2>
<ul>
<li>ユーザー名: {user_response['Username']}</li>
<li>メール: {user_info.get('email', 'N/A')}</li>
</ul>
<h2>トークン:</h2>
<p style="font-size:12px; word-break:break-all;">
{access_token[:50]}...
</p>
<a href="/">戻る</a>
</body>
</html>
'''
}
except ClientError as e:
return {
'statusCode': 400,
'headers': {'Content-Type': 'text/html; charset=utf-8'},
'body': f'''
<!DOCTYPE html>
<html>
<head><title>ログインエラー</title></head>
<body>
<h1>ログイン失敗</h1>
<p>エラー: {e.response['Error']['Message']}</p>
<a href="/">戻る</a>
</body>
</html>
'''
}
こちらをデプロイしていきます。
別途Lambdaの環境変数にアプリケーションクライアントIDとユーザプールIDを設定しておきましょう。一応、HTMLをレスポンスとして返すようにしたのですが、お察しのとおり「戻る」ボタンは機能しません。
③APIGatewayをセットアップしていきます。
今回はRESTAPIを作成していきます。「新しいAPI」を押下し、API名は「test-cognito-auth-api」とします。APIエンドポイントタイプは「リージョン」を指定します。
次にリソースを作成しましょう。今回はサインアップ、認証確認、ログインごとにフォームから別のURLパスに飛ばすので3つ作成します。(画像はサインアップ用です)
・リソースパスは「/」でリソース名は「signup」
・リソースパスは「/」でリソース名は「confirm」
・リソースパスは「/」でリソース名は「login」
COR(クロスオリジンリソース共有)は有効化しておきます。呼び出し元がローカルに保管したindex.htmlで、ブラウザからアクセスします。オリジン(プロトコル、ドメイン、ポートの情報)情報の異なるアクセス元がある場合は必ず有効化をする必要があります。今回の場合だとファイルの場所は「file:///C:/Users/(ユーザ名)/Desktop/(作業フォルダ)/index.html」にあるのでAPIGatewayで作成したオリジンとは全く異なりますね。
CORSを有効化すると「OPTIONS」メソッドが自動生成されます。リクエストが安全にできるかどうかサーバサイド側で自動確認してくれます。自動作成されるので、特にユーザ側は意識しなくて問題ないです。CORSについてはこちらをご参照ください。
今回は、各処理でLambdaにリクエストボディの情報をPUTするので新規メソッドを作成しましょう。
メソッドタイプは「PUT」を選択し、統合は「Lambda関数」にしましょう。今回はプロキシ統合を有効化することでリクエストの中身をすべてLambdaにパススルーするようにします。作成したLambda関数を指定しましょう。各リソースごとに同じ設定で作成すれば問題ないです。
ステージを作成し、APIをデプロイしていきましょう。ステージ名は「test」とします。
ステージを作成するとURLが払い出されるので、各リソースごとのステージURL+パスを控えておきます。
【サインアップ用】
【メール認証用】
【ログイン用】
これをindex.htmlの中に埋め込みます。URL情報はマスキングしてます。
<!DOCTYPE html>
<html>
<body>
<h1>Cognito認証システム</h1>
<h2>1. サインアップ(新規登録) </h2>
<form action="https://xxxxx.ap-northeast-1.amazonaws.com/test/signup" method="POST">
メール: <input type="email" name="email" required><br><br>
パスワード: <input type="password" name="password" required><br><br>
<button type="submit">サインアップ</button>
</form>
<hr>
<h2>2. メール確認</h2>
<form action="https://xxxxx.ap-northeast-1.amazonaws.com/test/confirm" method="POST">
メール: <input type="email" name="email" required><br><br>
確認コード: <input type="text" name="code" required><br><br>
<button type="submit">確認</button>
</form>
<hr>
<h2>3. サインイン(ログイン)</h2>
<form action="https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/test/login" method="POST">
メール: <input type="email" name="email" required><br><br>
パスワード: <input type="password" name="password" required><br><br>
<button type="submit">サインイン</button>
</form>
</body>
</html>
動作確認
フォームで検証してみます。
(実際のメールアドレスで試さないと、メール認証ができないので実際に使ってるアドレスで試してみてください。)
フォームを送信すると成功した場合は、「登録成功」と表示されます。
次にCognitoからメールアドレス宛に認証コードが送られてきます。
メールアドレスと、認証コードを打ち込みます。
メール確認完了と出力されました。
ログイン情報として、メールアドレスとパスワードを入力するとログイン成功しました。ユーザIDの情報と実際のメールアドレス、アクセストークンが表示されました。
これで処理自体はうまく動いているのですが、実際のユーザプールを確認してみます。
登録されていますね。サインアップ処理だけだと確認ステータスが「未確認」の仮登録状態ですね。メール認証してログインが正常に完了したメールアドレスは登録されています。
*あくまで個人検証なのでトークンが受け取れているかブラウザで一時的に確認していますが、ブラウザ側に情報が残るため個人検証以外では絶対やってはだめです。
最後に
お読みいただきありがとうございました。Cognitoの機能はまだまだ奥が深く触りの部分しか試せなかったので、もっと積極的に別機能を試してみたいですね。直近でやりたいのは、サーバーサイドを実際にアプリケーションと見立てたときに、運用保守で改修が走ると思います。その際に、Lambdaのエイリアス機能を使ってAPIGatewayステージ上でカナリアリリースをやってみるとか。。派生的に知識を広げていきたいと思います。