SPAからCognito User Poolに管理者としてユーザーを登録する 〜Cognito + Lambda + API Gateway + Angular〜

2018.10.22

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは、クラスメソッドの岡です。

今回、Cognitoで認証をかけているサイトにユーザー登録の機能をつけて、かつ管理者と一般ユーザーでログイン時の挙動を制御したいと思います。
amplifyではsignUpのAPIは提供されていますが、管理者としての登録はできないので、AWS SDKのadminCreateUserを使って実装します。

実装環境

ライブラリ バージョン
Angular CLI 6.0.8
Node.js 8.11.3
rxjs 6.2.1
typescript 2.7.2
Amplify 0.4.8

Amplifyはログイン周りの実装に使います。

調べてみる

まずcognitoのユーザープールにアカウントを作成するケースは、

  • ユーザーの自己サインアップ
  • 管理者としてユーザー登録(今回やる)
  • CSVファイルでインポート

の3パターンになります。

管理者として登録する場合のcognitoの認証フロー

①管理者がユーザー作成(AWSコンソールまたはAPI)
②ユーザー名と仮パスワードが招待メールで届く
③初回ログイン時に仮パスワードを変更する
④アカウントが有効になり変更後のパスワードでログインできる  

自己サインアップと違い作成時のパスワードが仮パスワードとして設定され、ログイン前にパスワードの変更が必要となります。
仮パスワードのままのユーザーはcognito上のステータスがFORCE_CHANGE_PASSWORDとなっていて、
このままだとログインができないので、初回ログイン時に仮パスワードの変更ができるよう実装したいと思います。

作ってみる

[cognito]ユーザープールの作成

AWSコンソールからcognitoを開き、ユーザープールを作成します。

[名前]任意のプール名を入力

[属性]サインインはユーザー名+検証済みのメールアドレス可とし、メールアドレスを必須項目にします。また権限を設定するためにカスタム属性を追加します。

[属性]タイプは string 、名前は role とします。

[ポリシー]管理者のみにユーザーの作成を許可

[アプリクライアント]SPAから接続するためにアプリクライアントを作成します。 アプリクライアントの追加 をクリック

任意の名前を入力して再度 アプリクライアントの追加 をクリックします。
クライアントシークレットを生成 のチェックを外し、前のステップで作成したカスタム属性へのアクセス権が付いていることを確認し手から アプリクライアントの作成をクリックします。

入力項目を確認して プールの作成 をクリックします。

プールの作成が完了したら、プールIDとアプリクライアントIDを控えておきます。

[Lambda]関数の作成

python3.6で実装します。

import boto3
import json
from decimal import Decimal
from datetime import datetime

def lambda_handler(event, context):
    client = boto3.client('cognito-idp')

    # ユーザーデータ取得
    userpoolid = event['UserPoolId']
    username = event['Username']
    attributes = event['UserAttributes']
    password = event['TemporaryPassword']
    
    # ユーザー作成
    response = client.admin_create_user(UserPoolId=userpoolid, Username=username, UserAttributes=attributes, TemporaryPassword=password)
    
    # レスポンス作成
    result = {
    "statusCode": response['ResponseMetadata']['HTTPStatusCode'],
    "body": json.dumps(response, default=default_proc)
    }
    return result

# JSONに変換するためのデータ処理
def default_proc(obj):
    if isinstance(obj, Decimal):
        return float(obj)
    elif isinstance(obj, datetime):
        return obj.isoformat()
    raise TypeError

[APIGateway]APIの作成

認証の設定

cognitoのユーザープールで認証をかけます。

左ペインの[オーソライザー]→[新しいオーソライザーの作成]を選択します。

  • タイプ: cognito
  • cognitoユーザープール:(上で作成したユーザープール)
  • トークンのソース: Authorization

で作成します。

リソースの作成

左ペインの[リソース]→[アクション]→[リソースの作成]

  • リソース名: User
  • リソースパス: /user

で作成します。

POSTメソッドの作成

作成したuserリソースを選択して、[アクション]→[メソッドの作成]を押します。

[Angular]フロントの実装

Cognitoの設定

先程作成したユーザープールの

environment.ts

export const environment = {
  production: false,
  Auth: {
        region: 'ap-northeast-1',
        userPoolId: 'ap-northeast-xxxxxxxxxxx', //作成したユーザープールのID
        userPoolWebClientId: 'xxxxxxxxxxxxxx' //作成したアプリクライアントのID
  },
  UserAPI: {
            name: 'APIName',
            endpoint: 'https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Test',
            region: 'ap-northeast-1',
            path: '/user'
  }
};

認証周りの処理

auth.service.ts

import { Injectable } from '@angular/core';
import { Observable, of, BehaviorSubject, from } from 'rxjs';
import { map, tap, catchError } from 'rxjs/operators';
import Amplify, { Auth } from 'aws-amplify';
import { environment } from './../../environments/environment';

Injectable()
export class AuthService {

  public loggedIn: BehaviorSubject<boolean>;
  password: string;
  userdata: string;
  reqAttrs: string;


  constructor(
    private router: Router
  ) {
    Amplify.configure(environment.Auth);
    this.loggedIn = new BehaviorSubject<boolean>(false);
  }

  // ログイン
  public signIn(username, password): Observable<any> {
    return from(Auth.signIn(username, password))
      .pipe(
        tap(() => this.loggedIn.next(true))
      );
  }

  // ログイン状態の取得
  public isAuthenticated(): Observable<boolean> {
    return from(Auth.currentAuthenticatedUser())
      .pipe(
        map(result => {
          this.loggedIn.next(true);
          return true;
        }),
        catchError(error => {
          this.loggedIn.next(false);
          console.log('isAuthenticated():', error);
          return of(false);
        })
      );
  }

  // ログアウト
  public signOut() {
    from(Auth.signOut())
      .subscribe(
        result => {
          this.loggedIn.next(false);
          this.router.navigate(['/login']);
        },
        error => console.log(error)
      );
  }


  // IDトークンの取得
  public getToken(): string {
    return Auth.currentSession()['__zone_symbol__value']['idToken']['jwtToken'];
  }


  // 仮パスワード変更
  public changeTemporaryPassword(user, password, requireAtt): Observable<any> {
    return from(Auth.completeNewPassword(user, password, requireAtt));
  }

}

ユーザー作成(サービス側)

user.service.ts

import { Injectable, Inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpResponse, HttpErrorResponse } from '@angular/common/http';
import { Observable, of, from, throwError } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { environment } from '../../environments/environment';

@Injectable({
  providedIn: 'root'
})


@Injectable()
export class UserService {



  constructor(
    @Inject(HttpClient) private http: HttpClient,
    private auth: AuthService
  ) {}

  private handleError<T>(operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {
      console.error(error);

      this.log(`${operation} failed: ${error.message}`);

      return of(result as T);
    };
  }

  private log(message: string) {
    console.log('userService: ' + message);
  }

  public CreateUser(token: string, body): Observable<any> {
    const endpoint = environment.UserAPI.endpoint;
    const path = environment.UserAPI.path;

    const Url = endpoint + path;

    const options = {
      headers: { Authorization: token }
    };
    return this.http.post<any>(Url, body, options).pipe(
      tap(res => res),
      catchError(this.handleError('getFile', []))
    );
   }
}

CreateUser() で作成したAPIGatewayの/userのPOSTメソッドを呼び出す。

ユーザー作成(コンポーネント側)

ユーザー登録用のコンポーネントを作成します。

ng g component user

user.component.ts

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { environment } from './../../../environments/environment';
import { UserService } from './../../services/user.service';
import { AuthService } from './../../services/auth.service';


@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {

  public userCreateForm: FormGroup;
  public successfullyUserCreate: boolean;

  constructor(
    private fb: FormBuilder,
    private auth: AuthService,
    private user: UserService
  ) { }

  ngOnInit() {
    this.initForm();
  }

  initForm() {
    this.userCreateForm = this.fb.group({
      'role': ['', Validators.required],
      'username': ['', Validators.required],
      'email': ['', Validators.required],
      'password': ['', Validators.required]
    });
  }

  onSubmitUserCreate(value) {
    // トークンの取得
    const token = this.auth.getToken();
    
    const userpool = environment.Auth.userPoolId;
    const userdata = {
        'UserPoolId': userpool,
        'Username': value.username,
        'TemporaryPassword': value.password,
        'UserAttributes': [
          {
          'Name': 'email',
          'Value': value.email
        },
        {
        'Name': 'custom:role',
          'Value': value.role
        }]
      };

    this.user.CreateUser(token, userdata).subscribe(
      result => {
        if (result['statusCode'] === 200) {
          this.successfullyUserCreate = true;
        } else {
          this.successfullyUserCreate = false;
          if (result['errorType'] === 'UsernameExistsException') {
            alert('ユーザー名が既に存在します。');
          } else {
          alert(result['errorMessage']);
          }
        }
      },
      error => {
        console.log(error);
      }
    );
  }

}

user.component.html

<app-navber></app-navber>
<div class="card text-center w-50 mx-auto">
    <div class="card-header">
        ユーザー登録
        </div>
  <div class="card-body">
      <div class="userCreate" *ngIf="!successfullyUserCreate">
        <form [formGroup]="userCreateForm" (ngSubmit)="onSubmitUserCreate(userCreateForm.value)">
          <div class="form-item" id="admin">
            <label><input type="radio" formControlName="role" name="role" value="admin" checked>管理者</label>
          </div>
          <div class="form-item" id="general">
            <label><input type="radio" formControlName="role" name="role" value="general">一般ユーザー</label>
          </div>
          <div class="form-group">
            <label>ユーザー名</label>
            <input type="text" formControlName="username" placeholder="username">
          </div>
          <div class="form-group">
            <label>メールアドレス</label>
            <input type="email" formControlName="email" placeholder="email">
          </div>
          <div class="form-group">
            <label>パスワード</label>
            <input type="password" formControlName="password" placeholder="password">
          </div>
            <button type="submit" class="btn btn-primary">Submit</button>
        </form>
        </div>
      
      <div class="user-create" *ngIf="successfullyUserCreate">
            <p>ユーザー登録が完了しました。</p>
      </div>
    </div>
  </div>

ログイン時の処理

ログインのフローとしては以下となります。

  • 仮パスワードの変更が必要なのでログイン時に仮パスワードの変更処理を実装します。
  • 仮パスワードが変更済みであればロールの値を取得してページの分岐処理をします。

login.component.ts

import { Component, OnInit } from '@angular/core';
import { AuthService } from './../../services/auth.service';
import { Router } from '@angular/router';
import { FormGroup, Validators, FormBuilder } from '@angular/forms';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
  public loginForm: FormGroup;
  public changePasswdForm: FormGroup;
  userdata: string;
  reqAttrs: any;
  newPasswordRequired: boolean;

  constructor(
    private formbuilder: FormBuilder,
    private auth: AuthService,
    private router: Router
    ) { }

  ngOnInit() {
    this.initForm();
  }

  initForm() {
    this.loginForm = this.formbuilder.group({
      'username': ['', Validators.required],
      'password': ['', Validators.required]
    });
    this.changePasswdForm = this.formbuilder.group({
      'newpassword': ['', Validators.required]
    });
  }

  onSubmitLogin(value: any) {
    const username = value.username, password = value.password;
    this.auth.signIn(username, password)
      .subscribe(
        result => {
          this.userObject = result;
          this.StatusCheck(this.userObject);
        },
        error => {
          console.log(error);
            alert('ログインに失敗しました。');
          }
        });
  }

  onSubmitChangePasswd(value: any) {
    const password = value.newpassword;
    console.log(this.userdata, this.reqAttrs);
    this.auth.changeTemporaryPassword(this.userdata, password, this.reqAttrs)
    .subscribe(
      result => {
        this.userNavigate(this.userObject);
        this.router.navigate([this.path]);
      },
      error => {
        console.log(error);
        alert('パスワードの変更に失敗しました。');
      }
    );
  }



  /*
  初期パスワードの変更処理とロール毎にページ遷移する
  */
  StatusCheck(userObject) {
   const hasChallenge = Object.prototype.hasOwnProperty.call(userObject, 'challengeName');
   this.reqAttrs = Object.prototype.hasOwnProperty.call(userObject, 'requiredAttributes');

   if (!hasChallenge) {
     // hasChallengeが無ければパスワード変更済み
     this.userNavigate(userObject);
     this.router.navigate([this.path]);
   } else if (userObject.challengeName === 'NEW_PASSWORD_REQUIRED') {
     console.log('NEW_PASSWORD_REQUIRED');
     alert('仮パスワードを変更する必要があります。\n新しいパスワードを入力してください。');
     this.userdata = userObject;
     this.newPasswordRequired = true;
     }
  }



  /*
  ロールで分岐してパスを代入
  */
  userNavigate(userObject) {
    this.auth.getUserAttributes(userObject).subscribe(
      attr => {
        this.role = this.ObtainRole(attr);
        if (this.role === 'admin') {
          this.path = '/admin';
        } else if (this.role === 'general') {
          this.path = '/home';
        }
      }
    );
    console.log(this.path);
  }

  /*
  カスタム属性値を取得
  */
  ObtainRole(attr): string {
    for (const i of attr) {
      if (i['Name'] === 'custom:role') {
        return i['Value'];
      }
    }
  }
}

仮パスワードの変更

ログインコンポーネントに仮パスワードの変更機能を追加します。

login.component.html

<div class="card text-center w-50 mx-auto">
    <div class="card-header">
    Login
    </div>
    <div class="card-body">
        <div class= "login" *ngIf="!newPasswordRequired">
            <form [formGroup]="loginForm" (ngSubmit)="onSubmitLogin(loginForm.value)">
                <div class="form-group text-left">
                    <label>ユーザー名 or メールアドレス</label>
                    <input type="text" formControlName="username" class="form-control" placeholder="Username or Email">
                </div>
                <div class="form-group text-left">
                    <label>パスワード</label>
                    <input type="password" formControlName="password" class="form-control" placeholder="Password">
                </div>
                    <button type="submit" class="btn btn-primary">Login</button>
            </form>
        </div>

        <div class="changePasswd" *ngIf="newPasswordRequired">
        <p>新しいパスワードを入力してください</p>
        <form [formGroup]="changePasswdForm" (ngSubmit)="onSubmitChangePasswd(changePasswdForm.value)">
            <div class="form-group">
                <input type="password" formControlName="newpassword" class="form-control" placeholder="New Password">
            </div>
                <button type="submit" class="btn btn-primary">パスワード変更</button>
        </form>
        </div>
    </div>
</div>

まとめ

Cognitoユーザープールに管理者としてのユーザー登録を、Angular+API Gateway+Lambdaで行う実装方法をご紹介しました。

今回ログイン処理をAmplifyで実装していますがログイン周りの説明は割愛しました。 Angular6+Amplifyのログイン、サインアップの実装は下記の記事で詳しく紹介されていますのでご参照ください。

AWS Amplify+Angular6+Cognitoでログインページを作ってみる ~バックエンド編~