ML NewsのWeb UIにアノテーションボタンを付けたい、でもユーザー管理機能は作りたくない(アノテーションするのは自分だけなので)。AWSのCognitoとAPI Gatewayを組み合せると簡単にできると聞いたので、やってみました。色々てこずったので正直あまり簡単ではなかったけど、やりたかったことはできたのでメモとして書き残しておきます。
やりたいことは以下。
- AWSの仕組みでいい感じにユーザー認証されたい
- 認証の仕組みを自前でやりたくない
- 認証されたユーザーのみが叩けるエンドポイントを工数少なく実現したい
- API Gatewayで所望のエンドポイントを生やす
- Cognitoの設定
- Amplifyで認証されたユーザーのみにアノテーションボタンを見せる
- Cognitoで認証されたユーザーのみAPI Gatewayを呼び出せるようにする
- 困ってるところ
- 参考
API Gatewayで所望のエンドポイントを生やす
認証をどうやるかは後回しにして、ひとまずエンドポイントをAPI Gatewayで生やしていきます。API Gatewayに生やしておくと後々の認証(Cognito)との接続がよいので、これを使います。
API Gatewayが何かを一言で説明するのは難しいけど、今回の用途に限れば裏側のロジックをLambdaで書きつつ、それをhttpで叩けるエンドポイントになってくれる君と思っておけばよい。別にlambdaじゃなくてもよいけど、今回はlambda。その他、リクエストをキャッシュさせたりといったこともできるようだが、今回の用途にはあまり関係ない。
ロジックをLambdaに書く
処理の本体はLambdaに書きます。Lambdaは色んな言語で書けるけど、手慣れているGolangで書きます。
Lambdaに書くロジック
package main import ( "net/http" "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-lambda-go/events" "os" "log" "fmt" "encoding/json" ) type MyRequest struct { Url string `json:"url"` Label model.LabelType `json:"label"` } var errorLogger = log.New(os.Stderr, "ERROR ", log.Llongfile) func UpdateExampleLabel(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { myReq := new(MyRequest) if err := json.Unmarshal([]byte(req.Body), myReq); err != nil { return clientError(http.StatusUnprocessableEntity) } // ここにロジックを書いていく return events.APIGatewayProxyResponse{ StatusCode: http.StatusOK, Body: `{"hoge": 1}`, Headers: map[string]string{ "Access-Control-Allow-Origin" : "*", // CORS対策 }, }, nil } func serverError(err error) (events.APIGatewayProxyResponse, error) { errorLogger.Println(err.Error()) return events.APIGatewayProxyResponse{ StatusCode: http.StatusInternalServerError, Body: http.StatusText(http.StatusInternalServerError), }, nil } func clientError(status int) (events.APIGatewayProxyResponse, error) { return events.APIGatewayProxyResponse{ StatusCode: status, Body: http.StatusText(status), }, nil } func main() { lambda.Start(UpdateExampleLabel) }
API Gatewayの設定
ロジックが書けたらaws-sam-cli
を使って手元でテストしたりするとよい。sam local start-api
とかやると手元でサーバーが走ります。
動作確認ができたらsamを使ってLambdaにデプロイしていく。今回の用途ではprivate subnet内のRDSに接続する必要があったため、VPC内にLambdaを作った。samのテンプレートはこんな感じ。
samのテンプレート
UpdateExampleLabel: Type: AWS::Serverless::Function Properties: Description: "事例のラベルを更新する" Runtime: go1.x Handler: update_example_label CodeUri: update_example_label/build # ビルドファイル設置ディレクトリ Timeout: 10 Policies: - VPCAccessPolicy: {} VpcConfig: SecurityGroupIds: - Fn::ImportValue: !Sub "${VPCStackName}:BatchSecurityGroup" SubnetIds: - subnet-xxx - subnet-yyy Environment: Variables: POSTGRES_HOST: XXXXX.YYYYY.us-east-1.rds.amazonaws.com Events: GetEvent: # 任意のイベント名 Type: Api Properties: Path: /update_example_label Method: post LambdaPermission: Type: "AWS::Lambda::Permission" Properties: FunctionName: !Ref UpdateExampleLabel Action: "lambda:InvokeFunction" Principal: "apigateway.amazonaws.com"
samをデプロイすると、API Gatewayのリソースも作られていると思う。
API Gatewayで作ったエンドポイントと元々のドメイン(www.machine-learning.news)は異なるため、CORS(Cross-Origin Resource Sharing)を有効にする必要がある。API Gatewayの画面から「CORSの有効化」を選択、200のレスポンスヘッダーに以下のものを追加しておく。
Cognitoの設定
こことここを参考にしながらAWSコンソールでボタンをポチポチ押していく。ユーザープールとIDプールを作成していく。注意するところとしてこんな感じかな。
- 認証プロバイダはCognitoだけで特に問題ない
- ユーザーの登録はcliでやっておく。そうしないとパスワード変更画面を作らないといけなくなるので、一手間増えてしまう
- 新規ユーザーを受け付ける場合はちゃんと画面を作りましょう...
- アカウントのステータスが
CONFIRMED
になっていればok
- 全般設定 => アプリクライアントからアプリクライアントを作っておく
- アプリの統合 => アプリクライアントの設定から「有効なIDプロバイダ」でcognitoを選択しておく
Amplifyで認証されたユーザーのみにアノテーションボタンを見せる
作成したユーザーがサインイン/アウトする画面はぐぐると色々出てくると思うので、それを参考に作る。今回は新しくユーザーがサインアップできる必要はなく、自分用のユーザーはcliで作成しているのでサインアップ画面は特に作らない。currentSession
かどうかを見てisAdmin
をtrue or falseにして、それによって出し分けする。
ボタンの出し分けに必要なJSの断片
import Amplify, { Auth, API, } from 'aws-amplify'; Amplify.Logger.LOG_LEVEL = 'DEBUG'; Amplify.configure({ Auth: { region: 'us-east-XXX', identityPoolId: 'us-east-XXX:YYY', userPoolId: 'ZZZ', userPoolWebClientId: 'AAAAA', }, }); export function IsAdmin() { return Auth.currentSession().then(_ => true).catch(_ => false); } export default { data () { return { ..., isAdmin: false, } }, mounted() { IsAdmin().then(isAdmin => this.isAdmin = isAdmin); }, }
Cognitoで認証されたユーザーのみAPI Gatewayを呼び出せるようにする
画面の上では認証されたユーザーのみにアノテーションボタンを見せられるようになったが、API Gateway自体はインターネットで公開されているエンドポイントになっているため、誰でもエンドポイントを叩けばアノテーションをいじれてしまう。それでは困るので、API自体もCognitoで認証済みのユーザーのみ叩けるようにする。
API Gatewayの設定
Cognitoを設定したときに自動的に作られてるIAM Role(arn:aws:iam::XXXXX:role/Cognito_YYYYYAuth_Role
のようなやつ)に追加で権限を付与する。invokeの権限を今回許可したいエンドポイントのみに与える。
{ "Sid": "VisualEditor1", "Effect": "Allow", "Action": "execute-api:Invoke", "Resource": "arn:aws:execute-api:us-east-1:XXXXX:YYYYY/*/POST/update_example_label" }
次にAPI Gatewayの画面のオーソライザーから「新しいオーソライザーの作成」を選択。自分が作成したユーザープールを指定する。
オーソライザーが作成できるとリソースの認証方法のCognitoが指定できるようになる。こんな感じ。
必須ではないと思うけど、「HTTPリクエストヘッダー」でAuthorization
を必須にしておくとよさそう。
ここまで設定したらAPIのデプロイを行なって変更点を反映する。
クライアント側の設定
クライアント側は簡単。localStorageから必要なtokenを取得してきて、リクエストヘッダーに付けるだけ。
idTokenを取得してAPI Gatewayを叩くクライアント側のコード
export default { props: ['example'], methods: { updateLabel(example, label) { let idToken = localStorage.getItem("CognitoIdentityServiceProvider.XXXXX.YYYYY.idToken"); let headers = { headers: { 'Authorization': idToken } }; axios.post( "https://ZZZZZ.execute-api.us-east-1.amazonaws.com/Prod/my_endpoint", { url: example.Url, label: label, }, headers ).then(response => { example.Label = label; }).catch(function (error) { alert(`Failed to annotate "${example.Title}"`); }) }, } }
困ってるところ
めちゃくちゃ困ってるというわけでもないけど、しばらくLambdaを起動させていなかった後の初回起動に時間がかかる。VPC内にLambdaを作った関係でENI(Elastic Network Interface)を作成する必要があるからだそうな。回避策としては定期的にポーリングしておくとよいらしいが、そこまで困ってるわけでもないので困りだしたらやる。