JavaScript
angular
認証
Firebase
Firestore

Angular+Firebase ユーザー設計とデータの保護

この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのルーティング設定(応用編)
次記事:準備中

この記事で行うこと

前回の記事ではAngularのガードを導入しました。
本稿ではAngular+Firebaseを使った場合のユーザー設計、およびデータの保護について扱っていきます。

Firestoreのデータ保護ルール

アプリケーションの設計をする際、DBにアクセスを行う入力値のバリデーション設計は、一定のセキュリティを担保する上で必須の工程です。ソースをすべて読むことができるクライアントサイドではこの処理を行うことができないため、サーバーサイドでどのように設計するかが肝になります。

Firestoreは「ルール」という機能でデータの機密性を担保しています。
認証情報の突き合わせや、アクセス範囲の限定といった処理は「ルール」を使って実装を行うため、認証情報を扱うアプリケーションの設計を行う場合は、必ずこの「ルール」を設定してください。

なお、Firestoreの「ルール」はコレクション、ドキュメント単位で適用されます。
フィールドの入力値は型の限定がされますが、個別のバリデーションについてはクライアントサイドで処理する必要がありますので注意してください。

参考

Cloud Firestore でのデータの保護

実装内容

ユーザーのデータ設計

今回実装するユーザーデータについての仕様を確認します。

  • Firebase Authとは別に、Firestoreでユーザーデータを持たせる
  • Firestoreに持たせたユーザーデータは、本人からしかアクセスできない
  • ユーザーが作成したコメントは、本人しか編集・削除できない
  • ログインすると自動的にFirestoreからユーザーデータを参照、ログアウトすると自動的に破棄する

Firebase Authとは別に、Firestoreでユーザーデータを持たせる

以前の記事でFirebase authenticationからuid等のユーザーデータを取得する方法を学習しました。Firebase authenticationではuid以外にも名前(displayName)やメールアドレス(email)、最終ログイン時(lastSignInTime)といったデータを取得することができます。

しかし、実際に設計をしていくと、上記のデータ以外にもユーザーデータとして本人以外にはアクセスさせたくないデータが存在することが多々あります。(例:設定情報やプライバシーにかかわるような情報など)
その場合、Firebase authenticationのデータとは別にFirestore上にユーザー用のコレクションを設定し、Firebase authentication上のデータ同様の認証条件を設けることでユーザーデータの拡張を行います。

今回Firestoreの設計は次のようにしました。

  ├── comments (コレクション)
  │    └── commentsID (ドキュメント)
  │          ├── content:string
  │          ├── date: number
  │          ├── initial: string
  │          ├── content:string
  │          ├── content:string
  │          └── user
  │               ├── name: string
  │               └── uid: string
  └── users(コレクション)
       └── userID(ドキュメント)
             ├── name: string
             └── uid: string

注意
上記の設計では、設計がわかりやすいようにuidが各コメントの参照用として登録されていますが、これによりユーザーIDが判別できてしまうという脆弱性を持っています。実際にプロダクトを設計するときは、uidがわからないようにするなどの対策をしてください。

Firestoreに持たせたユーザーデータは、本人からしかアクセスできない

上記で設定したusersコレクションは、ログイン認証を通過したユーザー本人でないと、データの取得、追加、編集、削除ができないようにします。

アクション 権限の範囲
取得(read) 本人のみ
追加(create) 本人のみ
編集(update) 本人のみ
削除(delete) 本人のみ

ユーザーが作成したコメントは、本人しか編集・削除できない

ユーザーが作成したコメントは、ユーザーのuidとそのコメントがもつuidとが一致しないとデータの編集、削除ができないようにします。
なお、データの取得に関しては認証に関係なく可とし、コメントの追加についてはログイン認証を通過していることを条件とします。

アクション 権限の範囲
取得(read) 認証不要
追加(create) 認証必要
編集(update) 本人のみ
削除(delete) 本人のみ

認証時のロジックをangularで実装

それでは上記設計をベースに実装していきます。

すでにFirebase authentication、Firestoreにデータがあるとエラーが発生しますので、実装前にデータをすべて削除してください。

ユーザー作成時にnameを追加

sign-up.component.htmlにnameフィールドを追加し、ユーザーの名前情報を登録できるようにします。

app/account/sign-up/sign-up.component.html
<div class="page">
  <section class="card">
    <form class="form-signup" (ngSubmit)="submitSignUp($event)" #signUpForm="ngForm">
      <h2 class="form-signup-heading">アカウント作成</h2>
      <label for="inputName" class="sr-only">Name</label> <!-- 追加 -->
      <input type="text" name="name"
             id="inputName" class="form-control"
             placeholder="Name"
             [(ngModel)]="account.name"
             #name="ngModel"
             autocomplete="username" required autofocus>

<!-- 以下、省略 -->
app/account/sign-up/sign-up.component.css
.form-signup #inputName { /*追加*/
  margin-bottom: -1px;
  border-bottom-right-radius: 0;
  border-bottom-left-radius: 0;
}
.form-signup #inputEmail {/*変更*/
  border-radius: 0;
  border-collapse: collapse;
  margin-bottom: -1px;
}
app/class/chart.ts
export class Password {
  name: string; // 追加
  email: string;
  password: string;
  password_confirmation: string;

  constructor() {
    this.name = ''; // 追加
    this.email = '';
    this.password = '';
    this.password_confirmation = '';
  }

  reset(): void {
    this.name = ''; // 追加
    this.email = '';
    this.password = '';
    this.password_confirmation = '';
  }
}

sessionサービスにユーザー作成・取得のメソッドを追加

session.service.tscreateUser()getUser()を追加し、usersコレクションの追加と取得ができるようにします。
また、Sessionクラスにuserを追加し、ログイン状況確認用のメソッドcheckLogin()でユーザーデータを取得できるように更新します。

app/core/service/session.service.ts
import { map, switchMap, take } from 'rxjs/operators'; // 追加
import { AngularFirestore } from '@angular/fire/firestore'; // 追加

  /* 省略 */

  constructor(private router: Router,
              private afAuth: AngularFireAuth,
              private afs: AngularFirestore) { // 追加
  }

  // ログイン状況確認
  checkLogin(): void { // 変更
    this.afAuth
      .authState
      .pipe(
        // authの有無でObservbleを変更
        switchMap(auth => {
          if (!auth) {
            return of(null);
          } else {
            return this.getUser(auth.uid);
          }
        })
      )
      .subscribe(auth => {
        this.session.login = (!!auth);
        this.session.user = (auth) ? auth : new User();
        this.sessionSubject.next(this.session);
      });
  }

  /* 省略 */

  // アカウント作成
  signup(account: Password): void {
    let auth;
    this.afAuth
      .auth
      .createUserWithEmailAndPassword(account.email, account.password) // アカウント作成
      .then(_auth => {
        auth = _auth;
        return auth.user.sendEmailVerification(); // メールアドレス確認
      })
      .then(() => { // 追加
        return this.createUser(new User(auth.user.uid, account.name));
      })
      .then(() => this.afAuth.auth.signOut()) // 追加
      .then(() => {
        account.reset(); // 追加
        alert('メールアドレス確認メールを送信しました。');
      })
      .catch(err => {
        console.log(err);
        alert('アカウントの作成に失敗しました。\n' + err);
      });
  }

  // ユーザーを作成
  private createUser(user: User): Promise<void> { // 追加
    return this.afs
      .collection('users')
      .doc(user.uid)
      .set(user.deserialize());
  }

  // ユーザーを取得
  private getUser(uid: string): Observable<any> { // 追加
    return this.afs
      .collection('users')
      .doc(uid)
      .valueChanges()
      .pipe(
        take(1),
        switchMap((user: User) => of(new User(uid, user.name)))
      );
  }
app/class/chart.ts
export class User {
  uid: string; // 変更(number -> string)
  name: string;

  constructor(uid?: string, name?: string) { // 変更
    this.uid = (uid) ? uid : '';
    this.name = (name) ? name : '';
  }

  deserialize() {
    return Object.assign({}, this);
  }
}

export class Session {
  login: boolean;
  user: User; // 追加

  constructor() {
    this.login = false;
    this.user = new User(); // 追加
  }

  reset(): Session {
    this.login = false;
    this.user =  new User(); // 追加
    return this;
  }
}
app/account/sign-up/sign-up.component.ts
  // アカウント作成
  submitSignUp(e: Event): void {
    e.preventDefault();
    // パスワード確認
    if (this.account.password !== this.account.password_confirmation) {
      alert('パスワードが異なります。');
      return;
    }
    this.session.signup(this.account);
    // this.account.reset(); 削除
  }

ObservableのpipetakeswitchMap

また新しいオペレータが登場したので、解説をしておきます。

pipe
RxJS 5.5から追加されたオペレータです。RxJS 6からはオペレータを使う際に必須のメソッドとなりました。
このメソッドは引数にオペレータをとり、親となるObservableをオペレータに渡します。そこで処理されたObservableが、次の引数にあるオペレータへと渡されていきます。

const observable = from([1, 2, 3, 4, 5])
.pipe(
    map((x) => x ** 2)
)
.subscribe(
    (x) => console.log(`value: ${x}`)
);
value: 1
value: 4
value: 9
value: 16
value: 25

take
引数で指定した回数だけ、ストリームから値を取得します。

const observable = from([1, 2, 3, 4, 5])
.pipe(
    take(2),
    map((x) => x ** 2)
)
.subscribe(
    (x) => console.log(`value: ${x}`)
);
value: 1
value: 4

switchMap
switchMap()は親のObservableを元に新しいObservableを作るオペレータです。switchMap()はデータの処理中に別の値がストリームを流れてくると、その処理を中断して新しい値の処理を開始します。

新しいObservableを作るという役割のオペレータには、ほかにconcatMap()mergeMap()があります。これらは値を取得するタイミングが異なりますが、どのような違いがあるかは参考を参照してください。

const observable = from([1, 2, 3, 4, 5])
.pipe(
    map((x) => x ** 2),
    switchMap((x) => Promise.resolve(x + 1))
)
.subscribe(
    (x) => console.log(`value: ${x}`)
);
value: 26 // ストリームにある最後の値だけを処理する

参考

RxJS 6: オペレータをつくってみる
RxJSのconcatMap, mergeMap, switchMapの違いを理解する(中級者向け)

チャット画面に反映する

それでは作成したユーザーデータをチャット画面に反映させます。
合わせて、ログアウトしたときに表示がおかしくならないよう、ログアウトにかかる処理も少し変更を加えます。

app/chat/chat.component.ts
import { SessionService } from '../core/service/session.service'; // 追加

// const CURRENT_USER: User = new User(1, 'Tanaka Jiro'); // 削除
// const ANOTHER_USER: User = new User(2, 'Suzuki Taro'); // 削除

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

  public content = '';
  public comments: Observable<Comment[]>;
  public current_user: User; // 変更

  // DI(依存性注入する機能を指定)
  constructor(private db: AngularFirestore,
              private session: SessionService) { // 追加
    this.session // 追加
      .sessionState
      .subscribe(data => {
        this.current_user = data.user; 
    });
  }

  /* 省略 */
app/core/service/session.service.ts
  logout(): void {
    this.afAuth
      .auth
      .signOut()
      .then(() => {
        return this.router.navigate([ '/account/login' ]);
      })
      .then(() => {
        this.sessionSubject.next(this.session.reset()); // 変更
        alert('ログアウトしました。');
      })
      .catch(err => {
        console.log(err);
        alert('ログアウトに失敗しました。\n' + err);
      });
  }

これでクライアント側の実装は完了です。
あとはFirestore側の設定を行います。

Firestoreのルールを設定する

Firestoreのルール設定は、FireBaseコンソールから行います。

NgChat – Database – Firebase console (4).png

Firestoreルールの書き方

現状では、すべてのデータが取得、編集可能な状態になっています。
Firestoreのルールは独自の記法で書かれているので、簡単にその内容について解説します。

// 初期設定(すべてのデータが取得、編集可能)
service cloud.firestore {
  match /databases/{database}/documents {

    match /{document=**} {
      allow read, write;
    }
  }
}

matchステートメント
matchステートメントは、条件を指定したいドキュメントを特定します。
内容は上方から判断され、もし同じ範囲の指定があった場合は、下方もしくはネストされたmatchが優先されます。
matchは必ずドキュメント単位で指定する必要があり、コレクション全体に適用したい場合はワイルドカード{value}を使用します。

{document=**}
任意のコレクション、ドキュメントを指定できます。下位のコレクション等すべてに適用したい場合は、この表記を使用します。
なお、再帰ワイルドカードは空のパスには一致しないため、match /cities/{city}/{document=**} のように指定した場合は、サブコレクションのドキュメントと一致しますが、cities コレクションのものには一致しません。

allow式
allowは条件を付与するオペレーションを指定します。if式で条件を指定していない場合は、無条件とみなされます。

オペレーション(read、write)
オペレーションは条件を付与するアクションを特定します。 大きく分けて、読み込み(read)と書き込み(write)があり、それらをさらに分割した詳細オペレーションも存在します。

大カテゴリ 小カテゴリ 役割
read get 1つのドキュメントを読み込む
read list 複数のドキュメントを読み込む
write create データの追加
write update データの更新
write delete データの削除

ルールを設定する

それでは、上記で設計した内容に従って、ルールを設定します。
ここで設定した内容は、以下のようになります。

service cloud.firestore {
  match /databases/{database}/documents {

    match /users/{userID} {
      allow read, write: if request.auth.uid == userID;
    }

    match /comments/{document=**} {
      allow read;
      allow create: if request.auth.uid != null;
      allow update, delete: if request.auth.uid == resource.data.user.uid;
      }
  }
}

request.auth.uid == userID
これはクライアント側のuidと、usersコレクションのドキュメントIDが一致することを条件とする、という意味になります。ワイルドカードでの指定なので、userIDuser_idのように記述しても構いません(おススメしませんが)。

if request.auth.uid != null
これはクライアント側のuidがあればよい、という意味になります。認証を通過していることを条件としたい場合は、この記述を利用します。

if request.auth.uid == resource.data.user.uid
これはクライアント側のuidと、Firestoreにあるデータ(comment)のuidが一致することを条件とする、という意味になります。resource.dataはmatchステートメントで指定したデータを参照することができます。もしルートからデータを参照したいような場合は、get(/databases/$(database)/documents/users/$(request.auth.uid)).data.nameのように記述します。

カスタム関数

上記if条件で示した内容は、function関数としてまとめることもできます。
複雑な条件を多用する場合は、カスタム関数を利用すると便利です。

service cloud.firestore {
  match /databases/{database}/documents {

    function isAuthor() {
      return request.auth.uid == resource.data.user.uid;
    }

    match /users/{userID} {
      allow read, write: if request.auth.uid == userID;
    }

    match /comments/{document=**} {
      allow read;
      allow create: if request.auth.uid != null;
      allow update, delete: if isAuthor();
      }
}

実行結果

Oct-11-2018 20-39-02.gif

これでユーザー認証の設定が完了しました。
次はAngularの状態管理(ngrx)について解説していきます。

ソースコード

この時点でのソースコード
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。