『自由力』を身に付けるサイト「リバトレ」も見てね!!

API GatewayのIAM認証をTypeScriptで通過する方法!※Angular編

悩んでいる男の子

API GatewayのIAM認証をTypeScriptで通過したいんだけど、どうしたらいいのかな?
AWSの設定と具体的なソースコードもあると嬉しいんだけど。。

こんな悩みを解決します。

  • 本記事の内容
  • IAM認証とは?
  • 具体的なAWSの各種設定
  • 認証を通過するための具体的なソースコード
  • 本記事の執筆者
プロフィール
どこの写真だよ
  • 長身ガリガリ自称イケメン(1993/4/1生)
  • 元エンジニア(歴7年)、資格保有数約20個(IT系以外も含む)
  • 副業(物販)5か月目で月利30万円⇒脱サラ
  • 物販、システム開発、アフィリエイト、投資を細々とやっています。
  • 物販は彼女と楽しみながらやってます!

今回は、 API GatewayのIAM認証をTypeScriptで通過する方法を解説します。

いずみん

無茶苦茶苦戦しました。。
公式ドキュメントは分かりにくいし、参考になる記事も少なかったので大変でした。。

今回はAngularフレームワークで実装します。

目次

API Gatewayの認証って?

API Gatewayには認証を設定できます。

認証を設定しないと誰でもAPIを実行できるので、当然認証を付けます。

認証には「Cognitoのオーソライザー」「AWS_IAM」などがあります。

Cognitoのオーソライザー

Cognitoの説明は割愛しますが、Cognitoから発行されるIDトークンをリクエストヘッダに載せることで認証を通過できます

Cognitoを使用しているフロントが良く使う認証です。

AWS_IAM

そのままですが、AWSのIAM認証を設定できます。

AWS_IAMの認証を通過するには、署名を作成し、その署名をリクエストヘッダに載せてリクエストを実行する必要があります。

つまりは、署名を作成する必要があるということですね。

リクエストに載せた署名と同じ作成手順をAWS側が行い、署名が正しいかどうかを検証しています。

署名はどうやって作る?

署名を作成する方法は何通りかあります。

  • ライブラリを使用して署名を作成する
  • API GatewayのSDK出力機能から出力されたソースを使用する
  • 署名作成処理を自作する

ライブラリを使用して署名を作成する

署名作成処理を行うライブラリは主に2つです。

  • aws4
  • aws/sdk/lib/coreのSighners.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のソースコードを生成できます。

署名作成処理を自作する

一番大変そうですが、結論こちらの方法で行いました。

これができれば、何かに依存することがなくなるのでベストです。

まずはAPI Gatewayを作る

さて、ここから実際に作業していきます。

まずはAPI Gatewayを作りますが、詳細については以下をご覧ください。

わざわざLambdaは作らなくてもOKです。
認証の通過を確認したいだけなのでモックで大丈夫です。

API GatewayにAWS_IAM認証を付ける

さて、先ほどの手順で「APIを実行するとレスポンスが返却される」ところまでは確認しました。

次は、AWS_IAM認証を付けて、「APIがエラーを返す」ところまでを確認します。

AWS_IAM認証付与

リソース」から任意のメソッドを選択し、「メソッドリクエスト」をクリックします。

認可を「なし」から「AWS_IAM」に変更します。

変更したら「APIのデプロイ」は忘れないように!!

APIをAWS_IAMが付いた状態で実行してみる

AWS_IAM認証を付けたので再度APIを実行してみます。

403エラー

ちゃんとエラーが返ってきますね。

ちなみに、APIのデプロイ後、反映されるのに数秒かかるので少し待ってから実行してください。

認証エラーは基本的に「403」エラーです。
リクエストヘッダに何も載せずに実行した場合は「{“message”: “Missing Authentication Token” }」というレスポンスが返却されます。

話は変わって認証(STS)の話をするよ

急に認証の話になって恐縮ですが、API Gatewayの設定以外にもやることがあります。

署名のソースコードを書いていくと分かるのですが、署名作成には認証情報(というより権限)が必要です。

AWSでは一時的な認証情報を「STS」と呼びます。
STSというサービスがあるわけではないので注意してください。

Angularの場合の認証情報

Angularアプリから、先ほど作成した認証付きAPI Gatewayへのアクセスが権限的に許可されていないと、そもそもAPIの実行ができません。

というより署名が作成できないです。

つまり、AngularアプリはAPIを実行するためのパワーが必要ということになります。

CognitoのIDプールから一時的な権限を払い出してもらう

S3の話になりますが、S3にデプロイされたアプリに対して一時的な権限を払い出す方法として「CognitoのIDプール」を利用する方法があります。

CognitoのIDプールから払い出された認証情報を元に署名を作成し、APIを実行するという流れになります。

また、払い出される認証情報には「API Gatewayの実行権限」が付与されている必要がありますが、実際にやっていけば理解できるので今は気にしなくて大丈夫です。

(おまけ)他サービスの認証情報取得方法

S3の場合は先ほどお話しした通り、「CognitoのIDプール」を利用する方法が主流です。

他のサービスでは以下のようになります。

サービス説明
EC2EC2に付与されているIAMロールから認証情報を取得する(IAMロールに適切なポリシーがアタッチされている前提)
Lambda予約環境変数からキー情報を取得する

CognitoのIDプールを作成する

CognitoのIDプールを作成します。

IDプール

IDプールの管理」をクリックします。

新しいIDプール

新しいIDプールの作成」で、上記のように設定します。

IDプール名」は何でもOKですが、注意点として「認証されていない ID に対してアクセスを有効にする」には必ずチェックを入れてください。
今回は未認証のユーザに対して権限を払い出すためです。

IDプールのIAM

作成するとIAMの画面に遷移するので、そのまま「許可」をクリックします。

これでIDプールの作成は完了です。

IDプールの未認証IAMロールにポリシーをアタッチする

IDプールは作成できましたが、これだけでは設定が不十分です。

今回は未認証のユーザに対して「API Gatewayの実行権限が付与された認証情報」を払い出す必要があるためです。

API GatewayのARN

ということで、IAMロールを編集したいのですが、その前に作成したAPI GatewayのARNをメモしておいてください。

Cognito_testUnauth_Role

IAMの画面を開き、「ロール」をクリックすると、先ほどIDプール作成時に作られた未認証ユーザのIAMロールがあるのでクリックします。

インラインポリシーの追加

インラインポリシーの追加」をクリックします。

サービス・アクション

サービスは「Execute API」、アクションは「Invoke」です。

ARNの追加

リソースですが、「ARNの追加」をクリックすると上記のような入力画面が出てくるので、ここに先ほどメモしたARNを貼り付けます。

上記のように設定できればOKなので、「追加」をクリック後、「ポリシーの確認」を押します。

ポリシーの確認

ポリシー名は適当ですが、分かりやすい名前を付けることをおススメします。

ポリシーの作成」をクリックすれば、すべて完了です。

Angularで署名作成処理を実装していく前に

ようやく本題です。

まず、AWSのクソ素晴らしい公式サイトを見てみましょう。

いずみん

↑に記載のタスク1~4を実装する必要があります。

署名バージョン4とは?

署名バージョンは1から始まったのですが、現在の最新は「バージョン4」になります。

2と4が有名なのですが、推奨バージョンは4なので、バージョン4で署名を作成する必要があります

バージョンが上がるごとに複雑になっているみたいですが、言い換えるとセキュリティが向上しているとも言えます。

Angularで署名作成処理を実装

実装部分以外の下準備

実装部分を載せる前に、処理の呼び出し元等のソースコードを載せておきます。

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が実行できれば何でもOKです!

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

下準備は終わったので、署名処理を担うサービスを作ります。

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);
      });
    });
  }
​
}

詳細は長いので割愛します。

このサービスを「app.module.ts」などに読み込ませるのを忘れないようにしましょう!

再度APIを実行してみる

APIを実行してみると、、、

再度APIを実行してみる

403エラーは出ず、APIのレスポンスが正しく返却されています。

いずみん

これが表示するまでに3日かかりました。。

まとめ

今回は、API GatewayのIAM認証をTypeScript で実装してみました。

はっきり言って、無茶苦茶大変でした。。

公式サイトが分かりにくいというのもありますが、単純に難易度が高かったですね。

ではまた!

AWS_IAM認証付与

この記事が気に入ったら
フォローしてね!

シェアするんやで!

~ リバトレ ~

お金や副業に関する情報を発信しているよ!

この記事を書いた人

いずみんのアバター いずみん 自由力発信おじ

【自由力発信】うさんくさ笑 | 副業物販で5ヶ月目に月利30万円達成⇨脱サラ予定 | 物販(アパレルせどり)・アフィリエイト・投資で自由になるための情報を発信中?笑 | 元エンジニア | 保有資格約20個

関連記事

コメント

コメントする

目次
閉じる