これまで、サーバーレスなSPA on AWSを実現しようとした場合、Cognitoによる認証部分の実装には、amazon-cognito-identity-jsというAWS製のJavaScriptライブラリを使うのが主流でした。
ただ、amazon-cognito-identity-jsを使う場合、このライブラリ本体の他にAWS SDKなどを含めた複数のライブラリを読み込む必要があったり、用意されているAPIの使い勝手が良くなかったりと、多少の辛みを感じる部分がありました。
今回は、AWSより新しくリリースされたAWS Amplifyというライブラリを使ってAngularアプリケーションにCognitoによる認証機能の実装を行ってみたいと思います。
AWS Amplifyの概要は、本アドベントカレンダー4日目の nakayama_cwさんがAWS AmplifyでサーバレスWebアプリの構築(Cognito + API Gateway + IAM認証)という記事で説明されていますので、そちらを御覧ください。
補足
AWS Amplifyには次の3つのパッケージが存在しており、ベースがaws-amplify、Reactでログイン周りのUIコンポーネントまで用意されているのがaws-amplify-react、React版と同様の機能をReactNative実装しているのがaws-amplify-react-nativeというようになっています。
※リンク先は、npm packageのページ
今回やること
上記で紹介したように、ベースのライブラリの他にReactのコンポーネントまでそろったライブラリが出ていますが、ここはあえてAngluarでもAWS Amplifyを試してみたいと思います。
今回は、aws-amplifyのAuth
モジュールを使って、Angluarアプリに次の機能を実装する手順を紹介します。
- サインアップ機能
- ログイン機能
- ログアウト機能
- Router Guardによる遷移操作
また、今回作成するサンプルの完成版は以下のリポジトリで公開しています。
angular-aws-amplify -GitHub
手順
環境
- Angluar
$ ng -v
...
Angular CLI: 1.6.1
Node: 9.2.0
OS: darwin x64
Angular:
...
- aws-amplify
0.1.20
AWS(Cognito)のリソース用意
Cognito User Poolの作成
AWS Consoleにログインし、Cognito -> 「ユーザープールの管理」-> 「ユーザープールを作成する」を選択します。
「ユーザープールをどのように作成しますか?」では、「ステップに従って設定する」を選択します。
また、次の手順でサインインの方法する画面になりますが、ここでは「E メールアドレスおよび電話番号」にチェックを入れます。
ユーザープールへのアクセス権限設定では、「アプリクライアントの追加」からメニューを展開します。
「アプリクライアント名」を入力し、「クラインとシークレットを生成する」のチェックを外してアプリクライントを作成します。
その他の項目はデフォルトのままで進めます。
Cognito Identity Poolの作成
Cognito -> 「フェデレーテッドアイデンティティの管理」を選択します。
選択すると、「新しい ID プールの作成」の画面が表示されるので、次のように設定して、「プールの作成」をクリックします。
認証プロバイダーにはCognitoを選択して、先ほど作成したユーザープールIDとアプリクライアントIDを入力します。
Angularプロジェクトの準備
まずは、プロジェクト作成します。
今回は、Router Guardによる遷移操作の実装まで行うため--routing
オプションをつけています。
$ ng new angular-aws-amplify --routing
次に必要なパッケージのインストールを行います。
必要なパッケージはAWS Amplifyのみです🎉
$ npm install aws-amplify --save
次に、AWS Amplifyの初期化時に渡すためのAWSの情報を設定ファイルに記述していきます。
今回は、ng new
した際に作成されている、src/environments/environment.ts
に記述していきます。
export const environment = {
production: false,
amplify: {
// 以下がAWS Amplify(Auth)の設定
Auth: {
identityPoolId: 'ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
region: 'ap-northeast-1',
userPoolId: 'ap-northeast-1_xxxxxxxxx',
userPoolWebClientId: 'xxxxxxxxxxxxxxxxxxxxxxxxx'
}
}
};
AuthService
認証系の処理をまとめたサービスを作成します。
$ ng g service auth/auth
コンストラクタでAmplify.configure()
に設定ファイルを渡して初期化する他、AWS AmplifyのAuthモジュールの機能をハンドリングするメソッドを実装していきます。
import { Injectable } from '@angular/core';
// 追加
import { Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { fromPromise } from 'rxjs/observable/fromPromise';
import { map, tap, catchError } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';
import Amplify, { Auth } from 'aws-amplify';
import { environment } from './../../environments/environment';
@Injectable()
export class AuthService {
public loggedIn: BehaviorSubject<boolean>;
constructor(
private router: Router
) {
Amplify.configure(environment.amplify);
this.loggedIn = new BehaviorSubject<boolean>(false);
}
/** サインアップ */
public signUp(email, password): Observable<any> {
return fromPromise(Auth.signUp(email, password));
}
/** 検証 */
public confirmSignUp(email, code): Observable<any> {
return fromPromise(Auth.confirmSignUp(email, code));
}
/** ログイン */
public signIn(email, password): Observable<any> {
return fromPromise(Auth.signIn(email, password))
.pipe(
tap(() => this.loggedIn.next(true))
);
}
/** ログイン状態の取得 */
public isAuthenticated(): Observable<boolean> {
return fromPromise(Auth.currentAuthenticatedUser())
.pipe(
map(result => {
this.loggedIn.next(true);
return true;
}),
catchError(error => {
this.loggedIn.next(false);
return of(false);
})
);
}
/** ログアウト */
public signOut() {
fromPromise(Auth.signOut())
.subscribe(
result => {
this.loggedIn.next(false);
this.router.navigate(['/login']);
},
error => console.log(error)
);
}
}
-
loggedIn(BehaviorSubject)
- グローバルなナビゲーションの表示を切り替えるために使っています。
-
signUp()、confirmSignUp()、signIn()、geUserInfo()
- AWS AmplifyのAuthモジュールのメソッドが返すPromiseをRxの
fromPromise
でObservableに変換するだけ。
- AWS AmplifyのAuthモジュールのメソッドが返すPromiseをRxの
-
isAuthenticated()
- ログイン状態にある場合には
Auth.currentAuthenticatedUser()
がPromiseのユーザー情報オブジェクトを返すため、それをBooleanのObservableに変換しています。(後ほど実装するGuardで使うため)
- ログイン状態にある場合には
-
signOut()
-
Auth.signOut()
を呼びつつ、ログイン画面に遷移させる。
-
AuthGuard
AngularのGuardでは、ルーティング前にセッションチェックなどの処理を行って、ルーティングの拒否を行うことができます。
$ ng g guard auth/auth
今回は、CanActivate
の機能を使って、ログイン状態にない場合に、login画面に画面遷移させるGuardを作成します。
CanActivate
はPromiseまたは、Observableの非同期処理を行った結果のbooleanを戻り値に取ることが出来るので、
先ほどのAuthServiceで作成したisAuthenticated()
メソッドの結果を使っています。
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
// 追加
import { of } from 'rxjs/observable/of';
import { map, tap, catchError } from 'rxjs/operators';
import { AuthService } from './auth.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private router: Router,
private auth: AuthService
) { }
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> {
return this.auth.isAuthenticated()
.pipe(
tap(loggedIn => {
if (!loggedIn) {
this.router.navigate(['/login']);
}
})
);
}
}
AppRoutingModule
ルーティングの定義を行います。
プロジェクト作成時に--routing
オプションを指定しているので、src/app/app-routing.module.ts
が作成されているはずです。このファイルを編集していきます。
import { NgModule } from '@angular/core';
import { Routes, RouterModule, CanActivate } from '@angular/router';
// 追加
import { LoginComponent } from './login/login.component';
import { SignupComponent } from './signup/signup.component';
import { HomeComponent } from './home/home.component';
import { AuthGuard } from './auth/auth.guard';
const routes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: HomeComponent, canActivate: [AuthGuard] },
{ path: 'login', component: LoginComponent },
{ path: 'signup', component: SignupComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
HomeComponent、LoginComponent、SignupComponentの3つのページがあり、HomeComponentをログインしたユーザーのみが閲覧できるようにするため、AuthGuard
でルーティングまえに検証しています。
※各ページのコンポーネントについてはあとで紹介します。
LoginComponent
ログインページのコンポーネントを作成します。
$ ng g component login
<p>login</p>
<div class="login">
<form [formGroup]="loginForm" (ngSubmit)="onSubmitLogin(loginForm.value)">
<p>
<label>Email: </label>
<input type="email" formControlName="email">
</p>
<p>
<label>Password: </label>
<input type="password" formControlName="password">
</p>
<button type="submit">Login</button>
</form>
</div>
import { Component, OnInit } from '@angular/core';
// 追加
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from './../auth/auth.service';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
public loginForm: FormGroup;
constructor(
private fb: FormBuilder,
private router: Router,
private auth: AuthService
) { }
ngOnInit() {
this.initForm();
}
initForm() {
this.loginForm = this.fb.group({
'email': ['', Validators.required],
'password': ['', Validators.required]
});
}
onSubmitLogin(value: any) {
const email = value.email, password = value.password;
this.auth.signIn(email, password)
.subscribe(
result => {
this.router.navigate(['/']);
},
error => {
console.log(error);
});
}
}
- onSubmitLogin()
- ログインフォームがサブミットされる際のイベントハンドラ。
AuthService
のsignIn()
メソッドの結果、ログインに成功したらhomeへ遷移します。
- ログインフォームがサブミットされる際のイベントハンドラ。
SignupComponent
サインアップ(登録)ページのコンポーネントを作成します。
このページでは、登録情報の入力と、確認コードの検証までを行います。
$ ng g component signup
<p>signup</p>
<button (click)="successfullySignup = true" *ngIf="!successfullySignup">メールアドレスと確認コードで認証</button>
<div class="signup" *ngIf="!successfullySignup">
<form [formGroup]="signupForm" (ngSubmit)="onSubmitSignup(signupForm.value)">
<p>
<label>Email: </label>
<input type="email" formControlName="email">
</p>
<p>
<label>Password: </label>
<input type="password" formControlName="password">
</p>
<button type="submit">Submit</button>
</form>
</div>
<div class="confirmation" *ngIf="successfullySignup">
<form [formGroup]="confirmationForm" (ngSubmit)="onSubmitConfirmation(confirmationForm.value)">
<p>
<label>Email: </label>
<input type="email" formControlName="email">
</p>
<p>
<label>Confirmation Code: </label>
<input type="text" formControlName="confirmationCode">
</p>
<button type="submit">Confirm</button>
</form>
</div>
import { Component, OnInit } from '@angular/core';
// 追加
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from './../auth/auth.service';
@Component({
selector: 'app-signup',
templateUrl: './signup.component.html',
styleUrls: ['./signup.component.css']
})
export class SignupComponent implements OnInit {
public signupForm: FormGroup;
public confirmationForm: FormGroup;
public successfullySignup: boolean;
constructor(
private fb: FormBuilder,
private router: Router,
private auth: AuthService
) { }
ngOnInit() {
this.initForm();
}
initForm() {
this.signupForm = this.fb.group({
'email': ['', Validators.required],
'password': ['', Validators.required]
});
this.confirmationForm = this.fb.group({
'email': ['', Validators.required],
'confirmationCode': ['', Validators.required]
});
}
onSubmitSignup(value: any) {
const email = value.email, password = value.password;
this.auth.signUp(email, password)
.subscribe(
result => {
this.successfullySignup = true;
},
error => {
console.log(error);
});
}
onSubmitConfirmation(value: any) {
const email = value.email, confirmationCode = value.confirmationCode;
this.auth.confirmSignUp(email, confirmationCode)
.subscribe(
result => {
this.router.navigate(['/login']);
},
error => {
console.log(error);
});
}
}
-
onSubmitSignup()
- サインアップフォームがサブミットされる際のイベントハンドラ。
AuthService
のsignUp()
メソッドの結果、サインアップに成功したら、successfullySignup
フラグをtrueにし、検証コードの入力フォームを表示しています。
- サインアップフォームがサブミットされる際のイベントハンドラ。
-
onSubmitConfirmation()
- 検証コードの入力フォームがサブミットされる際のイベントハンドラ。
AuthService
のconfirmSignUp()
メソッドの結果、サインアップに成功したらloginへ遷移します。
- 検証コードの入力フォームがサブミットされる際のイベントハンドラ。
HomeComponent
ログイン済みのユーザーのみが閲覧出来るホームページのコンポーネントを作成します。
$ ng g component home
今回は、ログイン後の機能に関しては実装していないのでコードの記載は省略します。
AppComponent
AppComponentには各ページ共通で表示するナビゲーションを配置しました。
<nav>
<ul class="main-nav">
<li><a [routerLink]="['/']">Angular AWS Amplify</a></li>
<li><a [routerLink]="['/']">ホーム</a></li>
<li *ngIf="!(auth.loggedIn | async)"><a [routerLink]="['/login']">ログイン</a></li>
<li *ngIf="(auth.loggedIn | async)"><a (click)="onClickLogout()">ログアウト</a></li>
<li><a [routerLink]="['/signup']">登録</a></li>
</ul>
</nav>
<router-outlet></router-outlet>
import { Component, OnInit, OnDestroy } from '@angular/core';
// 追加
import { Subscription } from 'rxjs/Subscription';
import { AuthService } from './auth/auth.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
subscription: Subscription;
public loggedIn: boolean;
constructor(
public auth: AuthService
) { }
ngOnInit() {
this.subscription = this.auth.isAuthenticated()
.subscribe(result => {
this.loggedIn = result;
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
onClickLogout() {
this.auth.signOut();
}
}
-
ナビゲーションのログイン・ログアウト
-
AuthService
のloggedIn
Subjectをasyncパイプで受け取って、表示する内容を変更しています。
-
-
ngOnInit()
- ページがリロードされた際にログイン状態を再取得するために
AuthService
のisAuthenticated()
メソッドを呼び出しています。
- ページがリロードされた際にログイン状態を再取得するために
-
onClickLogout()
- ログインボタンが押された際のイベントハンドラ。
AppModule
最終的なsrc/app/app.module.ts
は次のようになりました。
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
// 追加
import { AuthService } from './auth/auth.service';
import { AuthGuard } from './auth/auth.guard';
import { LoginComponent } from './login/login.component';
import { SignupComponent } from './signup/signup.component';
import { HomeComponent } from './home/home.component';
@NgModule({
declarations: [
AppComponent,
LoginComponent,
SignupComponent,
HomeComponent
],
imports: [
BrowserModule,
AppRoutingModule,
FormsModule,
ReactiveFormsModule,
],
providers: [
AuthService,
AuthGuard
],
bootstrap: [AppComponent]
})
export class AppModule { }
以上で、基本的な実装は完了です。
所感
今回、使用したAuth
のAPIに関しては、amazon-cognito-identity-jsではAWS SDKと組み合わせる必要があったりする部分ですが、AWS Amplifyがそこら辺の面倒な処理をうまくラップしてくれているおかげでかなり簡単にCognitoによる認証が実装できました。
細かいところですが、用意されているAPIがPromiseに統一されている部分も好感が持てます。
また、AWS Amplifyにはsigv4署名を簡単に行えるヘルパーメソッド的なものも用意されているようです。
今回は触れられませんでしたが、そのうち試してみたいと思っています。