angular
Firebase
CloudFirestore

Angular&Firebaseでユーザー認証してみる

この記事は ハンズラボ Advent Calendar 2017 10日目の記事です。

ハンズラボアドベントカレンダー2回目の登場@daikiojmです。
今回は、Firebaseのハンズオンネタを紹介します。
Firebaseと言うとリアルタイムデータベースが有名ですが、ユーザー認証基盤の構築も簡単に行うことができます。
この記事では、Angularの公式FirebaseライブラリAngularFireを使って、Angularアプリにユーザー認証機能を実装していきます。

今回作るもの

この記事ではAngular + AngularFireを使って、次の画面を作成します。

  • ログイン画面
  • サインアップ画面
  • トップページ(ユーザー情報)

また、Firebase Authenticationにはユーザー属性を設定する機能がないため、Cloud Firestoreでのユーザー情報管理も同時に行ってみたいと思います。

前提

  • Firebaseのアカウントは取得済み(無料枠で可)

今回使った環境

$ ng -v
...
Angular CLI: 1.5.4
Node: 9.2.0
OS: darwin x64
Angular: 5.0.3
... animations, common, compiler, compiler-cli, core, forms
... http, language-service, platform-browser
... platform-browser-dynamic, router

@angular/cli: 1.5.4
@angular/flex-layout: 2.0.0-beta.10-4905443
@angular-devkit/build-optimizer: 0.0.33
@angular-devkit/core: 0.0.21
@angular-devkit/schematics: 0.0.37
@ngtools/json-schema: 1.1.0
@ngtools/webpack: 1.8.4
@schematics/angular: 0.1.7
typescript: 2.4.2
webpack: 3.8.1

手順

Firebaseのプロジェクト作成

Firebaseのコンソールトップから「プロジェクト追加」をクリックして、プロジェクトを追加します。

01.png

Firebaseプロジェクトの設定

Authentication

次に、画面左側の「DEVELOP」メニューからAuthenticationを選択し「ユーザー」タブのログイン方法を設定ボタンから、ログインプロバイダの設定を行います。

02.png

今回は、メール/パスワードによるログインと、Googleアカウントログインの2つを有効にしてみました。

03.png

最後に、画面右上の「ウェブ設定」をクリックし表示されるスニペットのうち次の部分をコピーしておきます。

Key
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    storageBucket: '<your-storage-bucket>',
    messagingSenderId: '<your-messaging-sender-id>'

Database(Cloud Firestore)

次に、画面左側の「DEVELOP」メニューからDatabaseを選択し「FIRESTORE ベータ版を試してみる」をクリックします。

04.png

セキュリティルールを選択する画面が表示されるので、「テストモードで開始」にチェックを入れて「有効にする」をクリックします。

05.png

以上で、Firebase側の設定は完了です。

Angular CLIで雛形を作成

angluar-cliを使ってアプリの雛形を作成します。
あとで、認証済みかどうかを見てルーティングを行う処理を追加する予定なので、--routingオプションを付けて実行します。

$ ng new angular-firebase-auth --routing

コマンドの実行が終わったら、プロジェクトディレクトリに移動しておきます。

$ cd angular-firebase-auth

必要なパッケージのインストール

firebaseとAngularFireをインストールします。

$ npm install firebase angularfire2 --save

今回使ったのは、次のバージョンです。

プロジェクトのセットアップ

プロジェクトにAngularFireを使うための設定を行います。

Firebaseの設定を環境変数を定義する/src/environments/environment.tsに追加します。
apiKey、authDomain、ProjectIdはFirebaseのプロジェクト作成の手順でコピーした内容です。

environments.ts
export const environment = {
  production: false,
  firebase: {
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    messagingSenderId: '<your-messaging-sender-id>'
  }
};

次に、/src/app/app.module.tsを次のように編集します。

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

// 以下追加した項目
import { ReactiveFormsModule } from '@angular/forms';
import { AngularFireModule } from 'angularfire2';
import { AngularFireAuthModule } from 'angularfire2/auth';
import { AngularFirestoreModule } from 'angularfire2/firestore';
import { environment } from './../environments/environment';
import { AuthService } from './services/auth.service';
import { AuthGuard } from './guard/auth.guard';
import { UserLoginComponent } from './pages/user-login/user-login.component';
import { UserSignupComponent } from './pages/user-signup/user-signup.component';
import { UserInfoComponent } from './pages/user-info/user-info.component';

@NgModule({
  declarations: [
    AppComponent,
    UserLoginComponent,
    UserSignupComponent,
    UserInfoComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ReactiveFormsModule, // 追加
    AngularFireModule.initializeApp(environment.firebase), // 追加
    AngularFireAuthModule, // 追加
    AngularFirestoreModule // 追加
  ],
  providers: [
    AuthService,
    AuthGuard
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

メインのAngularFireModuleに加え、今回は認証機能を利用するためAngularFireAuthModuleをインポートしています。
この後の手順で追加するService、Componentのインポートも含まれていますが、それについては後述します。

ユーザーモデルの定義

Firestoreに保存するユーザー情報の型定義をフロント側で行っておきます。
Firebase Authenticationに保存するのが認証情報で、Firestoreに保存するのがユーザー情報で、これら2つはUIDによってリレーションが貼られているイメージです。
モデル(interface)は、app/modelsディレクトリに置くことにしました。

次のコマンドを実行してinterfaceを作成します。

$ ng g interface models/user

/src/models/user.tsに次のようにプロパティを定義します。

user.ts
export interface User {
  uid: string;
  email: string;
  displayName?: string;
  photoURL?: string;
  profile?: string;
}

UIDとメールアドレスは必須項目とし、それ以外はオプションとしています。
Googleアカウントでログインした場合は、Google登録情報からdisplayNameとphotoURLを取得するように後ほど実装します。

ユーザー認証サービスの作成

ユーザー認証周りの処理はサービスとして分割しておきます。サービスは、app/servicesディレクトリに置くことにしました。

次のコマンドを実行して認証サービスの雛形を作成します。

ng g service services/auth

/src/services/auth.service.tsを次のように編集します。

auth.service.ts
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';

// 以下追加したもの
import { AngularFireAuth } from 'angularfire2/auth';
import { AngularFirestore, AngularFirestoreDocument } from 'angularfire2/firestore';
import * as firebase from 'firebase/app';
import { Observable } from 'rxjs/Observable';
import { switchMap } from 'rxjs/operators';
import { User } from './../models/user';

@Injectable()
export class AuthService {
  user: Observable<User | null>;

  constructor(
    private router: Router,
    private afAuth: AngularFireAuth,
    private afStore: AngularFirestore
  ) {
    this.user = this.afAuth.authState
      .switchMap(user => {
        if (user) {
          return this.afStore.doc<User>(`users/${user.uid}`).valueChanges();
        } else {
          return Observable.of(null);
        }
      });
  }

  siginUp(email: string, password: string) {
    return this.afAuth.auth.createUserWithEmailAndPassword(email, password)
      .then(user => {
        return console.log(user) || this.updateUserData(user);
      })
      .catch(err => console.log(err));
  }

  login(email: string, password: string): Promise<any> {
    return this.afAuth.auth.signInWithEmailAndPassword(email, password)
      .then(user => {
        return console.log(user) || this.updateUserData(user);
      })
      .catch(err => console.log(err));
  }

  googleLogin() {
    const provider = new firebase.auth.GoogleAuthProvider();
    return this.oAuthLogin(provider);
  }

  logout() {
    this.afAuth.auth.signOut()
      .then(() => {
        this.router.navigate(['/login']);
      });
  }

  private oAuthLogin(provider) {
    return this.afAuth.auth.signInWithPopup(provider)
      .then(credential => {
        console.log(credential.user);
        return this.updateUserData(credential.user);
      })
      .catch(err => console.log(err));
  }

  private updateUserData(user: User) {
    const docUser: AngularFirestoreDocument<User> = this.afStore.doc(`users/${user.uid}`);
    const data: User = {
      uid: user.uid,
      email: user.email,
      displayName: user.displayName || '',
      photoURL: user.photoURL || '',
      profile: user.profile || ''
    };
    return docUser.set(data);
  }
}

ルーターガードの作成

Userがログイン状態かどうかを判別するguardを作成します。

まずは、次のコマンドを実行してguardの雛形を作成します。

$ ng g guard guard/auth 

app/guard/auth.guard.tsを次のように編集します。

auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Observable';

// 以下追加したもの
import { Router } from '@angular/router';
import { AngularFireAuth } from 'angularfire2/auth';
import { map, take, tap } from 'rxjs/operators';
import { AuthService } from './../services/auth.service';


@Injectable()
export class AuthGuard implements CanActivate {

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

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    return this.auth.user.pipe(
      take(1),
      map(user => !!user), // userが取得できた場合はtrueを返す
      tap(loggedIn => {
        if (!loggedIn) {
          this.router.navigate(['/loggin']);
        }
      })
    );
  }
}

siginUp、login、googleLogin、logoutそれぞれのメソッドでは、AngluarFireに用意されている認証系のAPIを叩いています。
結果は、Promiseで受け取れるので、実装も楽に行えるかと思います。
また、siginUp、login系のアクションを行った際には、ユーザー情報のDatabaseへの書き込みも同時に行っています。

ログインコンポーネントの作成

ログインページを表示するコンポーネントを作成します。

次のコマンドを実行してcomponentの雛形を作成します。

$ ng g component pages/user-login

クラス側

user-login.component.ts
import { Component, OnInit } from '@angular/core';

// 以下追加したもの
import { ReactiveFormsModule, FormGroup, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from './../../services/auth.service';

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

  loginForm: FormGroup;

  constructor(
    private router: Router,
    private fb: FormBuilder,
    private auth: AuthService
  ) {
    this.auth.user.subscribe(user => {
      if (user !== null) {
        this.router.navigate(['/']);
      }
    });
  }

  ngOnInit() {
    this.loginForm = this.fb.group({
      'email': ['', [Validators.required, Validators.email]],
      'password': ['', [Validators.required]]
    });
  }

  login() {
    const email = this.loginForm.get('email').value;
    const password = this.loginForm.get('password').value;
    this.auth.login(email, password)
      .then(() => {
        this.router.navigate(['/']);
      });
  }

  googleLogin() {
    this.auth.googleLogin()
      .then(() => {
        this.router.navigate(['/']);
      });
  }
}

テンプレート側

user-login.component.html
<h1>ログイン</h1>

<button (click)="googleLogin()">Googleでログイン</button>
<button [routerLink]="['/signup']">新規登録</button>

<form [formGroup]="loginForm" (ngSubmit)="login()">
  <label for="email">Email</label>
  <input type="email" class="input" formControlName="email" required >
  <label for="password">Password</label>
  <input type="password" class="input" formControlName="password" required >
  <button type="submit" class="button">ログイン</button>
</form>

サインアップコンポーネントの作成

ユーザー登録をするコンポーネントを作成します。
こちらもログインと同様です。

$ ng g component pages/user-singup

大半の部分はログインと同様なので一部省略しています。

クラス側

user-singup.component.ts
...
  signupForm: FormGroup;

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

  ngOnInit() {
    this.signupForm = this.fb.group({
      'email': ['', [Validators.required, Validators.email]],
      'password': ['', [Validators.required]]
    });
  }

  signup() {
    const email = this.signupForm.get('email').value;
    const password = this.signupForm.get('password').value;
    this.auth.siginUp(email, password)
      .then((x) => {
        this.router.navigate(['/']);
      });
  }
}

テンプレート側

user-singup.component.html
<h1>登録</h1>

<form [formGroup]="signupForm" (ngSubmit)="signup()">
  <label for="email">Email</label>
  <input type="email" class="input" formControlName="email" required >
  <label for="password">Password</label>
  <input type="password" class="input" formControlName="password" required >
  <button type="submit" class="button">登録</button>
</form>

ユーザー情報コンポーネントの作成

ログイン後にユーザー情報を表示するコンポーネントを作成します。
後述するルーティングの設定で、ルートへのアクセスはこのコンポーネントにリダイレクトするように設定します。

こちらもサインアップ、ログインと同様です。

$ ng g component pages/user-info

クラス側

user-info.component.ts
import { Component, OnInit } from '@angular/core';

// 以下追加したもの
import { AuthService } from './../../services/auth.service';

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

  constructor(public auth: AuthService) { }

  ngOnInit() {
    this.auth.user.subscribe(user => {
      console.log(user);
    });
  }

  logout() {
    this.auth.logout();
  }
}

テンプレート側

user-info.component.html
<div *ngIf="auth.user | async as user">
  <h1>ようこそ {{user.displayName}}</h1>
</div>
<button (click)="logout()">ログアウト</button>

<div *ngIf="auth.user | async as user">
  <h2>ユーザー情報</h2>
  <p>プロフィール画像:</p>
  <img [src]="user.photoURL" style="width: 150px">
  <p>UID: {{user.uid}}</p>
  <p>名前: {{user.displayName}}</p>
  <p>Email: {{user.email}}</p>
  <p>プロフィール: {{user.profile}}</p>
</div>

ルーティングの設定

前の手順で作成したuser-loginとuser-infoのコンポーネントをルーティングに追加していきます。
プロジェクト作成時に--routingオプションを指定したので、app以下にapp-routing.module.tsというファイルが作られているかと思います。

src/app/app-routing.module.tsを次のように編集します。

app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule, CanActivate } from '@angular/router';

// 以下追加したもの
import { UserLoginComponent } from './pages/user-login/user-login.component';
import { UserSignupComponent } from './pages/user-signup/user-signup.component';
import { UserInfoComponent } from './pages/user-info/user-info.component';
import { AuthGuard } from './guard/auth.guard';

const routes: Routes = [
  { path: '', redirectTo: '/userinfo', pathMatch: 'full' },
  { path: 'userinfo', component: UserInfoComponent, canActivate: [AuthGuard] },
  { path: 'login', component: UserLoginComponent },
  { path: 'signup', component: UserSignupComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

ルートにアクセスが有った場合には、/userinfoにリダイレクトするように設定。
また、GuardのcanActivateを使って、認証済みでないとアクセス出来ないよう制限する設定を行っています。

確認

開発サーバーでAngluarアプリを立ち上げて、作ったものを確認してみます。

$ ng serve

ブラウザから http://localhost:4200にアクセスします。
ログイン画面が表示されたら、「Googleでログイン」をクリックして使用するGoogleアカウントを選択します。

ログインに成功すると、次のようにユーザー情報が表示されているかと思います。

06_2.png

また、今回UIは用意しませんでしたが、「プロフィール」の値も取得できるかを確かめてみたいと思います。
Firebaseの管理画面から、ログイン中のユーザーのドキュメントのうち、プロフィールを編集してみたいと思います。

07.png

ユーザー情報を確認すると、内容がリアルタイムに反映されていることが確認できるかと思います。
08.png

注意

今回は、ひとまず試してみる程度の実装を行っているため、Formのバリデーションやライブラリからのレスポンスのエラーハンドリング、Firebaseの権限設定などはをかなり雑に行っています。実際に使用する際は、その点を見直す必要があると思います。

終わりに

以前、AWS Cognitoを使ってAngular アプリにユーザー認証機能を実装したことがありますが、認証基板側の設定、Angular側の実装、どちらをとってもFirebaseで行ったほうが手数が少なく簡単に行える印象です。
AWS Cognitoではユーザーの属性の設定や、グループ分けなどがデフォルトで行なえますが、FirebaseではFirestoreと組み合わせて行う必要がある点は注意が必要です。
実際にアプリケーションを構築する際には、ユーザー認証の他にもバックエンドのAPIやデータベース、静的ファイルのホスティングなどが必要になるので、必要な機能に応じてだと思いますが、Angular + AngularFireの組み合わせは簡単にSPA構築ができるのではないでしょうか。

実際に、alclimbさんのサーバーサイド不要説 ~ Angular&Firebaseを使ってがっつりサーバーレスなWEBサービスを開発・運用したノウハウという記事では、「Angular」と「Firebase」を使って完全なサーバーレスなWebサービスを実現しているようです。

ハンズラボ Advent Calendar 2017 11日目の明日は、こちらも2回目の@watarukuraさんです✨

参考

1. Installation and Setup -angularfire2
Angular2で学ぶFirebase入門 -HTML5 Experts.jp