久々に頭を悩ませた。。
まじで死にかけた。。
AWSの公式が死ぬほど分かりにくくて発狂した。
あと情報少なすぎて死にかけた。
皆やらないの?
API GatewayのIAM認証なんて普通にやるでしょ?
皆ガバガバセキュリティでやってんの?
ってことで今回は、API GatewayのIAM認証をTypeScriptで通過する方法を解説します。
ちなみにフレームワークは「Angular」でやります。
僕のプロフィールはこちら
まずAPI Gatewayって何?
ググれ。
API GatewayのIAM認証って?
API Gatewayには認証を設定できます。
認証を付けないと誰でもAPIを実行できるので、当然認証を付けるわけです。
で、認証には有名どころだと「Cognitoのオーソライザー」「AWS_IAM」などがあります。
Cognitoのオーソライザー
Cognitoの説明は割愛するとして、要はCognitoから発行されるIDトークンをリクエストヘッダに載せることで認証を通過できます。
Cognitoを使用しているフロントが良く使う認証です。
AWS_IAM
そのままですが、AWSのIAM認証を設定できます。
AWS_IAMの認証を通過するには、署名を作成し、その署名をリクエストヘッダに載せてリクエストを実行する必要があります。
要は、署名を作成する必要があるってことです。
どうやら、リクエストに載せた署名と同じ作成手順をAWS側が行い、署名が正しいかどうかを検証しているようです。
署名はどうやって作る?
さて、ここからは僕のストーリーのお話。
まずは、必殺奥義「ググる」を繰り出して、分かりやすいサイトがないかを調べてみました。
色々調べてみると、何通りか方法があるようです。
・ライブラリを使用して署名を作成する
・API GatewayのSDK出力機能から出力されたソースを使用する
・署名作成処理を自作する
ライブラリを使用して署名を作成する
Angularの話、というかnode_modulesの話、というかnpmの話、になるのかな?
まあ、署名処理をよろしくやってくれるライブラリを2つほど見つけました。
「aws4」と「aws-sdk/lib/coreのSigners.V4クラス」です。
が、結論どっちもうまくいきませんでした。
「aws-sdk/lib/coreのSigners.V4クラス」に関してはうまくいかなかった理由がはっきりしないのですが、とりあえずイライラしすぎたのでやめました。
「aws4」に至ってはライブラリ使おうとしたらエラー出ました。
イライラが爆発して禿げそうだったのでライブラリを使用するやり方は撤退しました。
API GatewayのSDK出力機能から出力されたソースを使用する
これは正直論外です。
API GatewayはSDKというファイル群を出力でき、これを使えば面倒な署名処理をやってくれるんですが、実はこれAPI Gatewayの設定に依存したソースコードです。
一部だけなので問題ないっちゃないんですが、厄介なのはAPI Gatewayの設定を変えたらソースも置きなおす必要があることです。
また、フロントから叩くAPI Gatewayが複数あった場合、両方のAPI GatewayからSDKを出力する必要があります。
何よりファイル名おんなじっす。
ってことで、これはイケてないってことで却下。
ただ、SDKのソースコードは便利なので重宝しました。
というのも、このソースコードを使えば署名処理をしなくていいということは、このソースコードのどこかに署名処理を行っている箇所があるってことです。
知る必要がないので割愛しますが、まあ中のソースコードを貪りましたよ、ほんとに。
ちなみに↑からSDKのソースコードを生成できます。
署名作成処理を自作する
一番大変そうですが、結果これでやりました。
これができれば、何かに依存することがなくなるのでベストです。
っていうのは分かっていたのですが、極力やりたくなかったんです。
なぜかって?
AWSの公式がクソ分かりくいからじゃーーーーーー。
(おまけ)世の中の記事について
色々と調べると、まあやりたいことに近しいサイトがそれなりにはありました。
ただ、SDKを利用したやり方とか、これから説明する認証情報取得のやり方をハショッたやり方とか、「え?そこ知りたいんだけど?」っていう部分が書かれていないものが多かったです。
SDKを使うやり方とか現実的じゃないし、署名作成処理も重要ですが、認証情報の取り方とかって結構知りたい部分なんですけどね。
アクセスキー・シークレットキーをべた書きしているソースコードとかもありましたが、ベストプラクティスに反してるし、誰がそんなやり方するねんって感じですよ、ほんと。
まあただの愚痴ですよ、ほんとに。
まずはAPI Gatewayを作りましょ
さて、ここから実際に作業していきます。
まずはAPI Gatewayを作りますが、詳細については以下を見てくださいまし。
※わざわざLambdaとかは作らなくてもいいです。
認証の通過を確認したいだけなのでMockで十分です。
API GatewayにAWS_IAM認証を付ける
さて、先ほどの手順で「APIを実行するとレスポンスが返却される」ところまでは確認しました。
次は、AWS_IAM認証を付けて、「APIがエラーを返す」ところまでを確認します。
「リソース」から任意のメソッドを選択し、「メソッドリクエスト」をクリックします。
認可を「なし」から「AWS_IAM」に変更します。
変更したら「APIのデプロイ」は忘れないように。
APIをAWS_IAMが付いた状態で実行してみる
AWS_IAM認証を付けたので再度APIを実行してみます。
ちゃんとエラーが返ってきますね。
ちなみに、APIのデプロイ後、反映されるのに数秒かかるので少し待ってから実行してください。
認証エラーは基本的に「403」エラーです。
リクエストヘッダに何も載せずに実行した場合は「{“message”: “Missing Authentication Token” }」というレスポンスが返却されます。
話は変わって認証(STS)の話をするよ
急に認証の話になって恐縮ですが、API Gatewayの設定以外にもやることがあります。
署名のソースコードを書いていくと分かるんですが、署名作成には認証情報(というか権限)が必要です。
AWSでは一時的な認証情報を「STS」と呼びます。
STSというサービスがあるわけではないので注意。
Angularの場合の認証情報
例えば、AngularであればS3にビルドした静的ファイルをデプロイすると思います。
Angularアプリから先ほど作成した認証付きAPI Gatewayへのアクセスが権限的に許可されていないと、そもそもAPIの実行ができません、というか署名が作成できないです。
つまり、AngularアプリはAPIを実行するためのパワーが必要ってことです。
CognitoのIDプールから一時的な権限を払い出してもらう
S3の話になりますが、S3にデプロイされたアプリに対して一時的な権限を払い出す方法として「CognitoのIDプール」を利用する方法があります。
CognitoのIDプールから払い出された認証情報を元に署名を作成し、APIを実行するという流れになります。
また、払い出される認証情報には「API Gatewayの実行権限」が付いている必要がありますが、ここは実際にやっていけば理解できるので今は気にしなくていいです。
(おまけ)他サービスの認証情報取得方法
S3の場合は先ほどお話しした通り、「CognitoのIDプール」を利用する方法が多いかと思います。
他のサービスでは以下のようになります。
・EC2:EC2に付与されているIAMロールから認証情報を取得する(IAMロールに適切なポリシーがアタッチされている前提)
・Lambda:予約環境変数からキー情報を取得する
CognitoのIDプールを作成する
ってことで、CognitoのIDプールを作成します。
「IDプールの管理」をクリックします。
「新しいIDプールの作成」から、上記のように設定します。
「IDプール名」は何でもいいですが、注意点として「認証されていない ID に対してアクセスを有効にする」には必ずチェックを入れてください。
今回は未認証のユーザに対して権限を払い出すためです。
作成するとIAMの画面に遷移するので、そのまま「許可」をクリックします。
これでIDプールの作成は完了です。
IDプールの未認証IAMロールにポリシーをアタッチする
IDプールは作成できましたが、これだけでは設定が不十分です。
今回は未認証のユーザに対して「API Gatewayの実行権限が付与された認証情報」を払い出す必要があるためです。
ってことで、IAMロールをいじりたいんですが、その前に作成したAPI GatewayのARNをメモしておいてください。
IAMの画面を開き、「ロール」をクリックすると、先ほどIDプール作成時に作られた未認証ユーザのIAMロールがあるはずなのでこれをクリックします。
僕の場合だと「Cognito_testUnauth_Role」です。
「インラインポリシーの追加」をクリックします。
サービスは「Execute API」、アクションは「Invoke」です。
リソースですが、「ARNの追加」をクリックすると上記のような入力画面が出てくるので、ここに先ほどメモしたARNを貼り付けます。
上記のように設定できればOKなので、「追加」をクリック後、「ポリシーの確認」を押します。
ポリシー名は適当ですが、分かりやすい名前を付けることをおススメします。
「ポリシーの作成」をクリックし、すべて完了です。
Angularで署名作成処理を実装していく前に
ようやく本題です。
まず、AWSのクソ素晴らしい公式サイトを見てみましょう。
↑に記載のタスク1~4を実装する必要があります。
パッと見て「できそう!!」って思った方は天才なので、本記事を見る必要すらありません。
署名バージョン4とは?
元々署名バージョンは1から始まったみたいですが、現在の最新は「バージョン4」になります。
2と4が有名みたいですが、推奨はバージョン4なので4で署名を作成する必要があります。
バージョンが上がるごとに複雑になっているみたいですが、言い換えるとセキュリティ的な面で向上しているとも言えます。
Angularで署名作成処理を実装
公式を見てもらってもいいですが、面倒だしクソファッキン分かりにくいので、別に見てもらわなくてもいいです。
実装部分以外の下準備
実装部分を載せる前に、処理の呼び出し元であったりのソースコードを載せておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
ngOnInit() { this.hoge(); } private async hoge() { const header = await this.apigSigV4Service.makeRequest('GET', '/test/', undefined, undefined, undefined); this.get(header); } private get(header: any) { this.httpService.get<any>( 'https://*****.execute-api.ap-northeast-1.amazonaws.com/test/' , { headers: new HttpHeaders(header) } ).subscribe( (response: any) => { console.log(response); }, (error: HttpErrorResponse) => { } ); } |
↑は呼び出し元のソース(の一部)です。
適当ですが、まあAPI叩けりゃ何でもいいっす。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class HttpService { constructor( private http: HttpClient, ) { } /** * getメソッドを呼び出す * * @param url URL * @return 同期処理 */ public get<T>(url: string): Observable<T> { return this.http.get<T>(url); } } |
「httpService.ts」です。
僕はサービス化するのが好きなんですが、これも好みでOKです。
apig-sig-v4.service.ts
さて、下準備は終わったので、署名処理を担うサービスを作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 |
import { Injectable } from '@angular/core'; import * as AWS from 'aws-sdk'; /** * API Gateway署名バージョン4サービス * * NOTE: * 変数名・関数名はAPI Gatewayから出力されるSDKと合わせる * * @author K.Izumi */ @Injectable() export class ApigSigV4Service { /** * クリプトJS */ private CryptoJS = require('crypto-js'); /** * サービス名 * ※API Gatewayの場合は下記で固定 */ private SERVICE = 'execute-api'; /** * ホスト名 */ private HOST = '*****.execute-api.ap-northeast-1.amazonaws.com'; /** * リージョン */ private REGION = 'ap-northeast-1'; /** * AWS4リクエスト */ private AWS4_REQUEST = 'aws4_request'; /** * ハッシュアルゴリズム */ private AWS_SHA_256 = 'AWS4-HMAC-SHA256'; /** * AWS4 */ private AWS4 = 'AWS4'; /** * デフォルトコンテンツタイプ */ private DEFAULT_CONTENT_TYPE = 'application/json'; /** * ヘッダキー */ private headerKey = { accept: 'Accept', authorization: 'Authorization', contentType: 'Content-Type', host: 'host', xAmzDate: 'x-amz-date', xAmzSecurityToken: 'x-amz-security-token', }; /** * 現在時刻を取得する * * @return 現在時刻 */ private datetimeFor(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[:\-]|\.\d{3}/g, ''); } /** * URIをエンコードする * * @param str クエリ文字列 * @return URI(エンコード後) */ private fixedEncodeURIComponent (str: string): string { return encodeURIComponent(str).replace(/[!'()*]/g, (c) => { return '%' + c.charCodeAt(0).toString(16).toUpperCase(); }); } /** * メッセージダイジェストを生成する(SHA-256) * * @param value 任意の値 * @return メッセージダイジェスト */ private hash(value) { return this.CryptoJS.SHA256(value); } /** * ダイジェストのBase16エンコードを小文字で返す * * @param value 任意の値 * @return ダイジェストのBase16エンコード(小文字) */ private hexEncode(value) { return value.toString(this.CryptoJS.enc.Hex); } /** * メッセージ認証コード化 * * @param secret シークレットキー * @param value 値 * @return メッセージ認証コード化後の値 */ private hmac(secret: string, value: string) { return this.CryptoJS.HmacSHA256(value, secret, {asBytes: true}); } /** * 正規URI生成 * * @param uri URI ※HTTPホストヘッダーからクエリ文字列パラメータ(存在する場合)を開始する疑問符("?")までのすべて * @return 正規URI */ private buildCanonicalUri(uri: string): string { return encodeURI(uri); } /** * 正規クエリ文字列生成 * * @param queryParams クエリパラメータ * @return 正規クエリ文字列 */ private buildCanonicalQueryString(queryParams: object): string { if (Object.keys(queryParams).length < 1) { return ''; } const sortedQueryParams = []; for (const property in queryParams) { if (queryParams.hasOwnProperty(property)) { sortedQueryParams.push(property); } } sortedQueryParams.sort(); let canonicalQueryString = ''; for (let i = 0; i < sortedQueryParams.length; i++) { canonicalQueryString += sortedQueryParams[i] + '=' + this.fixedEncodeURIComponent(queryParams[sortedQueryParams[i]]) + '&'; } return canonicalQueryString.substr(0, canonicalQueryString.length - 1); } /** * 正規ヘッダ生成 * * @param headers ヘッダ * @return 正規ヘッダ */ private buildCanonicalHeaders(headers: object): string { let canonicalHeaders = ''; const sortedKeys = []; for (const property in headers) { if (headers.hasOwnProperty(property)) { sortedKeys.push(property); } } sortedKeys.sort(); for (let i = 0; i < sortedKeys.length; i++) { canonicalHeaders += sortedKeys[i].toLowerCase() + ':' + headers[sortedKeys[i]] + '\n'; } return canonicalHeaders; } /** * 署名付きヘッダ生成 * * @param headers ヘッダ * @return 署名付きヘッダー */ private buildCanonicalSignedHeaders(headers: object): string { const sortedKeys = []; for (const property in headers) { if (headers.hasOwnProperty(property)) { sortedKeys.push(property.toLowerCase()); } } sortedKeys.sort(); return sortedKeys.join(';'); } /** * 正規リクエスト生成 * * @param method HTTPメソッド ※大文字 * @param path パス * @param queryParams クエリパラメータ * @param headers ヘッダ * @param payload ペイロード * @return 正規リクエスト */ private buildCanonicalRequest(method: string, path: string, queryParams: object, headers: object, payload: object|string): string { return method + '\n' + this.buildCanonicalUri(path) + '\n' + this.buildCanonicalQueryString(queryParams) + '\n' + this.buildCanonicalHeaders(headers) + '\n' + this.buildCanonicalSignedHeaders(headers) + '\n' + this.hexEncode(this.hash(payload)); } /** * 認証情報スコープ作成 * * @param datetime 現在時刻 * @return 認証情報スコープ */ private buildCredentialScope(datetime: string): string { return datetime.substr(0, 8) + '/' + this.REGION + '/' + this.SERVICE + '/' + this.AWS4_REQUEST; } /** * 署名文字列作成 * * @param datetime 現在時刻 * @param credentialScope 認証情報スコープ * @param hashedCanonicalRequest 正規リクエスト(ハッシュ化後) * @return 署名文字列 */ private buildStringToSign(datetime: string, credentialScope: string, hashedCanonicalRequest: string): string { return this.AWS_SHA_256 + '\n' + datetime + '\n' + credentialScope + '\n' + hashedCanonicalRequest; } /** * 署名キー取得 * * @param secretKey シークレットキー * @param datetime 現在日付 * @return 署名キー */ private calculateSigningKey(secretKey: string, datetime: string): string { return this.hmac( this.hmac( this.hmac( this.hmac( this.AWS4 + secretKey, datetime.substr(0, 8) ), this.REGION ), this.SERVICE ), this.AWS4_REQUEST ); } /** * 署名計算 * * @param key キー * @param stringToSign 署名文字列 * @return 署名 */ private calculateSignature(key: string, stringToSign: string) { return this.hexEncode(this.hmac(key, stringToSign)); } /** * Authorizationヘッダ署名情報取得 * * @param accessKey アクセスキー * @param credentialScope 認証情報スコープ * @param headers ヘッダ * @param signature 署名 */ private buildAuthorizationHeader(accessKey: string, credentialScope: string, headers: object, signature): string { return this.AWS_SHA_256 + ' Credential=' + accessKey + '/' + credentialScope + ', SignedHeaders=' + this.buildCanonicalSignedHeaders(headers) + ', Signature=' + signature; } /** * 署名バージョン4を作成する * * NOTE: * 署名に関するエラーはAPI実行時にしか分からないため、API実行側でエラーハンドリングする * * トラブルシューティングは以下 * https://docs.aws.amazon.com/ja_jp/general/latest/gr/signature-v4-troubleshooting.html * * @param method HTTPメソッド ※大文字 * @param path パス * @param queryParams クエリパラメータ * @param headers ヘッダ * @param body ボディ * @return 署名バージョン4 * @link https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4_signing.html */ public makeRequest(method: string, path: string, queryParams: object, headers: object, body: object|string) { return new Promise((resolve) => { /** * 初期処理 */ const datetime = this.datetimeFor(); if (body === undefined || body === '' || body === null || Object.keys(body).length === 0) { body = undefined; } if (queryParams === undefined) { queryParams = {}; } if (headers === undefined) { headers = {}; } if (headers[this.headerKey.contentType] === undefined) { headers[this.headerKey.contentType] = this.DEFAULT_CONTENT_TYPE; } if (headers[this.headerKey.accept] === undefined) { headers[this.headerKey.accept] = this.DEFAULT_CONTENT_TYPE; } if (body === undefined || method === 'GET') { body = ''; } else { body = JSON.stringify(body); } // ボディがない場合、ヘッダからコンテンツタイプを削除し、署名バージョン4の計算に含まれないようにする if (body === '' || body === undefined || body === null) { delete headers[this.headerKey.contentType]; } headers[this.headerKey.xAmzDate] = datetime; headers[this.headerKey.host] = this.HOST; /** * タスク1:署名バージョン4の正規リクエストを作成する * * @link https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-create-canonical-request.html */ const canonicalRequest = this.buildCanonicalRequest(method, path, queryParams, headers, body); const hashedCanonicalRequest = this.CryptoJS.SHA256(canonicalRequest).toString(this.CryptoJS.enc.Hex); /** * タスク2:署名バージョン4の署名文字列を作成する * * @link https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-create-string-to-sign.html */ const credentialScope = this.buildCredentialScope(datetime); const stringToSign = this.buildStringToSign(datetime, credentialScope, hashedCanonicalRequest); /** * Cognito IDプールから一時認証情報(STS)を取得する */ AWS.config.region = this.REGION; AWS.config.credentials = new AWS.CognitoIdentityCredentials({ // TODO: environmentに記載 IdentityPoolId: 'ap-northeast-1:*****' }); (<AWS.CognitoIdentityCredentials>AWS.config.credentials).get(() => { const accessKeyId = AWS.config.credentials.accessKeyId; const secretAccessKey = AWS.config.credentials.secretAccessKey; const sessionToken = AWS.config.credentials.sessionToken; /** * タスク3: AWS署名バージョン4の署名を計算する * * @link https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-calculate-signature.html */ const signingKey = this.calculateSigningKey(secretAccessKey, datetime); const signature = this.calculateSignature(signingKey, stringToSign); /** * タスク4: HTTPリクエストに署名を追加する * * @link https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-add-signature-to-request.html */ headers[this.headerKey.authorization] = this.buildAuthorizationHeader(accessKeyId, credentialScope, headers, signature); /** * ヘッダ生成処理 */ headers[this.headerKey.xAmzSecurityToken] = sessionToken; delete headers[this.headerKey.host]; // コンテンツタイプが指定されていない場合、再アタッチする必要あり if (headers[this.headerKey.contentType] === undefined) { headers[this.headerKey.contentType] = this.DEFAULT_CONTENT_TYPE; } resolve(headers); }); }); } } |
どや!!
解説したいところですが、説明はすべてAWSの公式に載っているので割愛!!
このサービスを「app.module.ts」などに読み込ませるのを忘れないように!
再度APIを実行してみる
APIを実行してみると、、、
来た!!
403エラーは出ず、APIのレスポンスが正しく返却されてます。
これが表示するまでに3日かかりました。
まとめ
久々に頭を悩ませました。
AWSの公式にはPythonのサンプルコードはあるんですが、他の言語の方がニーズあると思うんですが。。
そもそもAPIを実行するのはフロントだし、サーバ側でやるにしてもPHPとかじゃない?
API GatewayのSDK出力機能がなかったら絶対できてなかった、難易度高すぎ。
さいなら。