この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのルーティング設定(応用編)
次記事:Angularのngrxを使って状態管理を行う(理論編)
この記事で行うこと
前回の記事ではAngularのガードを導入しました。
本稿ではAngular+Firebaseを使った場合のユーザー設計、およびデータの保護について扱っていきます。
Firestoreのデータ保護ルール
アプリケーションの設計をする際、DBにアクセスを行う入力値のバリデーション設計は、一定のセキュリティを担保する上で必須の工程です。ソースをすべて読むことができるクライアントサイドではこの処理を行うことができないため、サーバーサイドでどのように設計するかが肝になります。
Firestoreは「ルール」という機能でデータの機密性を担保しています。
認証情報の突き合わせや、アクセス範囲の限定といった処理は「ルール」を使って実装を行うため、認証情報を扱うアプリケーションの設計を行う場合は、必ずこの「ルール」を設定してください。
なお、Firestoreの「ルール」はコレクション、ドキュメント単位で適用されます。
フィールドの入力値は型の限定がされますが、個別のバリデーションについてはクライアントサイドで処理する必要がありますので注意してください。
参考
(追記:2020/6)現時点(2020年6月)での最新の内容に書き換えています。
実装内容
ユーザーのデータ設計
今回実装するユーザーデータについての仕様を確認します。
- 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 (ドキュメント)
│ ├── date: number
│ ├── initial: string
│ ├── content:string
│ └── user: map
│ ├── name: string
│ └── uid: string
└── users(コレクション)
└── userID(ドキュメント)
├── name: string
└── uid: string
注意
上記の設計では、設計がわかりやすいようにuidが各コメントの参照用として登録されていますが、これによりユーザーIDが判別できてしまうという脆弱性を持っています。実際にプロダクトを設計するときは、uidがわからないようにするなどの対策をしてください。
(2020/07/24追記)@Yz_4230さんから脆弱性とは何かというコメントをいただき、実際に検証を行なってみました。
if request.auth.uid != userID
というルールでアクセス制限を設定しているため、ユーザーIDがわかってしまうと他ユーザーの情報も読み取れるのではという懸念からの注釈だったのですが、実際にFirestore REST APIを用いて検証してみたところ、通信に必要なリクエスト情報はトークンIDのみで、そこにuidは含まれていなかったため、uidを参照用に設定しても情報流出はなさそうです。
Firestoreに持たせたユーザーデータは、本人からしかアクセスできない
上記で設定したusersコレクションは、ログイン認証を通過したユーザー本人でないと、データの取得、追加、編集、削除ができないようにします。
アクション | 権限の範囲 |
---|---|
取得(read) | 本人のみ |
追加(create) | 本人のみ |
編集(update) | 本人のみ |
削除(delete) | 本人のみ |
ユーザーが作成したコメントは、本人しか編集・削除できない
ユーザーが作成したコメントは、ユーザーのuidとそのコメントがもつuidとが一致しないとデータの編集、削除ができないようにします。
なお、データの取得に関しては認証に関係なく可とし、コメントの追加についてはログイン認証を通過していることを条件とします。
アクション | 権限の範囲 |
---|---|
取得(read) | 認証不要 |
追加(create) | 認証必要 |
編集(update) | 本人のみ |
削除(delete) | 本人のみ |
認証時のロジックをangularで実装
それでは上記設計をベースに実装していきます。
すでにFirebase authentication、Firestoreにデータがあるとエラーが発生しますので、実装前にデータをすべて削除してください。
ユーザー作成時にnameを追加
sign-up.component.html
にnameフィールドを追加し、ユーザーの名前情報を登録できるようにします。
<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>
<!-- 以下、省略 -->
.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;
}
export class Password {
name: string; // 追加
email: string;
password: string;
passwordConfirmation: string;
constructor() {
this.name = ''; // 追加
this.email = '';
this.password = '';
this.passwordConfirmation = '';
}
reset(): void {
this.name = ''; // 追加
this.email = '';
this.password = '';
this.passwordConfirmation = '';
}
}
sessionサービスにユーザー作成・取得のメソッドを追加
session.service.ts
にcreateUser()
とgetUser()
を追加し、usersコレクションの追加と取得ができるようにします。
また、Sessionクラスにuserを追加し、ログイン状況確認用のメソッドcheckLogin()
でユーザーデータを取得できるように更新します。
import { Injectable } from '@angular/core';
import { Observable, of, Subject } from 'rxjs'; // 更新
import { map, switchMap, take } from 'rxjs/operators'; // 更新
import { User as fbUser } from 'firebase/app'; // 追加
import { Router } from '@angular/router';
import { AngularFireAuth } from '@angular/fire/auth';
import { AngularFirestore } from '@angular/fire/firestore'; // 追加
import { Password, Session, User } from '../class/chat'; // 更新
/* 省略 */
constructor(private router: Router,
private afAuth: AngularFireAuth,
private afs: AngularFirestore) { // 追加
}
// ログイン状況確認
checkLogin(): void { // 変更
this.afAuth
.authState
.pipe(
// authの有無でObservableを変更
switchMap((auth: fbUser | null) => {
if (!auth) {
return of(null);
} else {
return this.getUser(auth.uid);
}
})
)
.subscribe((user: User | null) => {
this.session.login = (!!user);
this.session.user = (user) ? user : new User();
this.sessionSubject.next(this.session);
});
}
// ログイン状況確認(State)
checkLoginState(): Observable<Session> {
return this.afAuth
.authState
.pipe(
map((auth: fbUser | null) => { // 更新
// ログイン状態を返り値の有無で判断
this.session.login = (!!auth);
return this.session;
})
);
}
/* 省略 */
// アカウント作成
signup(account: Password): void {
let auth: firebase.auth.UserCredential;
this.afAuth
.createUserWithEmailAndPassword(account.email, account.password) // アカウント作成
.then((TEMP_AUTH: firebase.auth.UserCredential) => {
auth = TEMP_AUTH;
return auth.user.sendEmailVerification(); // メールアドレス確認
})
.then(() => { // 追加
return this.createUser(new User(auth.user.uid, account.name));
})
.then(() => this.afAuth.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) => {
if (user) {
return of(new User(uid, user.name));
} else {
return of(null);
}
})
);
}
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;
}
}
// アカウント作成
submitSignUp(e: Event): void {
e.preventDefault();
// パスワード確認
if (this.account.password !== this.account.passwordConfirmation) {
alert('パスワードが異なります。');
return;
}
this.session.signup(this.account);
// this.account.reset(); 削除
}
Observableのpipe
、take
、switchMap
また新しいオペレータが登場したので、解説をしておきます。
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の違いを理解する(中級者向け)
チャット画面に反映する
それでは作成したユーザーデータをチャット画面に反映させます。
合わせて、ログアウトしたときに表示がおかしくならないよう、ログアウトにかかる処理も少し変更を加えます。
import { SessionService } from '../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 currentUser: User; // 変更
// DI(依存性注入する機能を指定)
constructor(private db: AngularFirestore,
private session: SessionService) { // 追加
this.session // 追加
.sessionState
.subscribe(data => {
this.currentUser = data.user;
});
}
/* 省略 */
logout(): void {
this.afAuth
.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コンソールから行います。
Firestoreルールの書き方
現状では、すべてのデータが取得、編集可能な状態になっています。
Firestoreのルールは独自の記法で書かれているので、簡単にその内容について解説します。
// 初期設定(すべてのデータが取得、編集可能)
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write;
}
}
}
matchステートメント
matchステートメントは、条件を指定したいドキュメントを特定します。
内容は上方から判断され、もし同じ範囲の指定があった場合は、下方もしくはネストされたmatchが優先されます。
matchは必ずドキュメント単位で指定する必要があり、コレクション全体に適用したい場合はワイルドカード{value}
を使用します。
{document=**}
任意のコレクション、ドキュメントを指定できます。下位のコレクション等すべてに適用したい場合は、この表記を使用します。
なお、再帰ワイルドカードは空のパスには一致しないため、match /cities/{city}/{document=**}
のように指定した場合は、サブコレクションのドキュメントと一致しますが、cities コレクションのものには一致しません。
(2020/06追記)こちらはバージョン1の挙動です。
バージョン2の場合、match /cities/{city}/{document=**}
はサブコレクションのドキュメントと一致し、かつcities コレクションのドキュメントにも一致します。
また、バージョン2の場合にはワイルドカードを match ステートメント内の任意の場所に配置できるようになっています。
例)match /{path=**}/songs/{song}
allow式
allowは条件を付与するオペレーションを指定します。if
式で条件を指定していない場合は、無条件とみなされます。
オペレーション(read、write)
オペレーションは条件を付与するアクションを特定します。 大きく分けて、読み込み(read
)と書き込み(write
)があり、それらをさらに分割した詳細オペレーションも存在します。
大カテゴリ | 小カテゴリ | 役割 |
---|---|---|
read | get | 1つのドキュメントを読み込む |
read | list | 複数のドキュメントを読み込む |
write | create | データの追加 |
write | update | データの更新 |
write | delete | データの削除 |
rules_version = '2'
について
2019年5月以降、Firebaseセキュリティルールでバージョン2が使えるようになりました。バージョン2では、以下の変更が加わっています。
- 再帰的なワイルドカード {name=**} の動作を変更
- コレクショングループクエリの使用
なお、バージョン2を使うためには、セキュリティルールで rules_version = '2';
を最初の行にオプトインする必要があります。
ルールを設定する
それでは、上記で設計した内容に従って、ルールを設定します。
ここで設定した内容は、以下のようになります。
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が一致することを条件とする、という意味になります。ワイルドカードでの指定なので、userID
はuser_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();
}
}
実行結果
これでユーザー認証の設定が完了しました。
次はAngularの状態管理(ngrx)について解説していきます。
ソースコード
この時点でのソースコード
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。