はじめに
この記事は、Youtubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact-Redux講座 実践編』の学習備忘録です。
前回記事はこちら。
要約
- 認証状態の永続化のため、
認証のリッスン機能
を作成する - 認証のリッスン機能を持たせた
<Auth>
コンポーネントで、リッスン対象をラッピングする - パスワードリセット機能は、firebase.authの機能で簡単に実装できる。
#3 Firebase Authで認証をリッスンしよう
認証状態の永続化
前回講座までで「サインアップ」「サインイン」の機能を実装しましたが、今の状態ではまだ不完全です。
例えば、サインイン直後ではユーザーの情報が state に正しく保存されているので、Home画面にはユーザーIDやユーザー名が表示されます。
しかし、この状態で画面をリロードしたり、ブラウザを立ち上げ直して再度アクセスすると、 state は初期状態に戻ってしまいます。
これは、React の state は、画面をリロードしたり、ブラウザを立ち上げ直したりすると初期化されてしまう性質を持っているからです。
これでは、 state の初期化が走るたびにサインインし直す必要があり、Webアプリとしては非常に使い勝手が悪くなってしまいます。
そのため、「ユーザー認証がすでに行われている状態であれば、適宜データベース側と通信してユーザー情報を state に保存する」という処理を入れ必要があります。
これを実現するのが認証のリッスン
という処理なのですが、その前に一度、React
+firebase
における認証の仕組みを簡単に整理しておきます。
React + firebase による認証の仕組み
そもそも firebase.Auth
では、ユーザーの認証情報
をどのように判断するのでしょうか?言い換えると、判断元となるユーザーの認証情報
は、どこに保存されているのでしょうか?
こちらのteratail記事(『firebase.auth().onAuthStateChangedはどうやってログイン中であることを判定しているんでしょうか?』)によると、firebase.Auth
は「サインアップまたはサインイン処理を行った時、ユーザーの認証情報をindexedDB
という、ブラウザ内にあるデータベースに保存する」とのことです。
これは、「ブラウザ内にある情報を保存する場所」という意味ではlocalStorage
に近い役割のものです。これらの違いについて詳しくは調べていませんが、ここで大切なのは「ブラウザ側に認証情報
が保存される」ということです。
そのため、React
+firebase
アプリにおいて、
- React の state に保存されている
ユーザー情報
- firebaseにより ブラウザの
indexedDB
へ保存された認証情報
がそれぞれ存在しているということになります。
ユーザー情報を画面描画に使用するためには、当然 React の state に値を入れる必要がありますが、これは画面リロードのたびに消えてしまいます。
対して、indexedDB
に保存された情報は、画面リロードやブラウザ立ち上げなどでは削除されません。ただし、ここで保存している認証情報
は、ユーザー名やメールアドレスなどのユーザー情報そのものではなく、「データベース(Cloud Firestore)から特定のユーザー情報を引き出すための鍵」のようなもので、そのまま使用することができないものです。
そのため、これらを組み合わせた以下のような流れで「認証状態の永続化」を実現します。
- 画面のリロードなどが行われた時、まず
indexedDB
を見て認証の有無を確認。 - 未認証なら initialState を state に入れて画面描画。認証済みであれば、
indexedDB
に保存された認証上を用いてCloud Firestoreと通信し、特定のユーザーの情報を取ってくる。 - 取ってきたユーザー情報を state に入れて画面描画する
このような流れを実装することで、サインインを行った特定のユーザーのサインイン状態が永続化し、何度もサインインをし直すという状態を防ぐことができます。
この今、ユーザーが認証しているかどうかを確認し、state に適切な値を入れる一連の作業のことを、認証のリッスンと呼びます。
認証のリッスン
Reactで認証のリッスンを行うためには、「認証のリッスン処理を行うコンポーネント
を作成し、これで対象のコンポーネントをラッピングする」というやり方をとります。
認証のリッスン処理を行うためのコンポーネント
は、一般的には<Auth>
という名前で定義されます。
<Auth>
コンポーネントをRouter.jsx
において呼び出し、「認証済みの状態でのみ描画したいコンポーネント」をラッピングすることで、上記処理を実現します。
import React from 'react';
import {Route, Switch} from "react-router";
import {Home,SignIn,SignUp} from "./templates";
import Auth from "./Auth"
const Router = () => {
return (
<Switch>
<Route exact path={"/signup"} component={SignUp} />
<Route exact path={"/signin"} component={SignIn} />
//追記
<Auth>
<Route exact path={"(/)?"} component={Home} />
</Auth>
//追記ここまで
</Switch>
);
};
export default Router
SignUp, SignIn画面は、認証のリッスンを行いません。なぜなら、ユーザー情報を state に保存する必要がない(=未認証のユーザーでもアクセス可能)なページにしたいからです。
一方、path={"(/)?"}
へアクセスすると、<Auth>
コンポーネントにより認証のリッスンが行われます。
「認証がされていればコンポーネントを描画」「未認証であれば別の処理(例えばSignInへリダイレクト)」という条件分岐を行うことで、認証状態でのみアクセス可能な画面を実装できます。
認証リッスン関数を作成
<Auth>
コンポーネントを作る前に、認証リッスンを実行するlistenAuthState
関数を作成します。
firebase authでは、現在認証中のユーザーを取得するためのメソッドとして、onAuthStateChanged()
があります。これを使用することで認証のリッスンを簡単に実装することができます。
users stateに関わる関数のため、signUp
関数や signIn
関数と同様に、users/operations.js に定義していきます。
import { signInAction } from "./actions";
import {push} from "connected-react-router";
import {auth, db, FirebaseTimestamp} from "../../firebase/index"
export const listenAuthState = () => {
return async (dispatch) => {
return auth.onAuthStateChanged(user => {
if (user) {
const uid = user.uid
db.collection("users").doc(uid).get()
.then(snapshot => {
const data = snapshot.data()
dispatch(signInAction({
isSignedIn: true,
role: data.role,
uid: uid,
username: data.username
}))
})
} else {
dispatch(push("/signin"))
}
})
}
}
.
.
.
auth.onAuthStateChanged()
というメソッドは、ユーザーの認証状態に応じて、返り値を変える(条件分岐して処理を変えることができる)メソッドです。
onAuthStateChanged()
メソッドを実行すると、アプリはブラウザ内のindexedDB
を見に行き、そこにユーザーに関する情報があれば取ってきて、user
という返り値を返します。
if(user)
、すなわちuser
が存在しているのであれば、それはユーザーが認証済みということになるので、user.uid
の情報をもとにCloud firestore
から該当するユーザーの情報を取得し、stateに保存します。(また、Cloud firestore
との通信を行うため、この関数はreturn async (dispatch) ...
という redux-thunk を利用した書き方を行い、非同期制御をする必要があります)
逆に、user
が存在しないのであれば、ユーザーが未認証の状態ということになるので、dispatch(push("/signin"))
でサインイン画面にリダイレクトをさせます。
Authコンポーネントの作成
先ほどのlistenAuthState()
関数を利用して<Auth>
コンポーネントを作成し、<Auth>
タグで囲まれたコンポーネントには、認証リッスンが走るようにします
ひとつ事前準備として、<Auth>
コンポーネントでは、ユーザーのサインイン情報を示すstateであるuser.isSignedIn
を使用するため、それを取得するためのselectorを定義します。
import { createSelector } from "reselect";
const usersSelector = (state) => state.users;
export const getSignedIn = createSelector(
[usersSelector],
state => state.isSignedIn
)
.
.
.
getSignedIn ()
と記述することで、Store内のuser.isSignedIn
の値を外部からでも参照できるようになりました。これも用いて、<Auth>
コンポーネントを作成します。
<Auth>
コンポーネントを作成する場所は決められていませんが、今回は使用先のRouter.jsx
と同階層であるsrc
ディレクトリ直下に作ります。
import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { getSignedIn } from "./reducks/users/selectors";
import { listenAuthState } from "./reducks/users/operations";
const Auth = ({children}) => {
const dispatch = useDispatch();
const selector = useSelector((state) => state);
const isSignedIn = getSignedIn(selector);
useEffect(() => {
if (!isSignedIn) {
dispatch(listenAuthState())
}
}, []);
if (!isSignedIn) {
return <></>
} else {
return children
}
};
export default Auth;
children
とは、「子要素全体」を意味する特別なpropsです。例えば、アクセスしているアドレスが(/)
であれば、<Route exact path={"(/)?"} component={Home} />
がここに丸ごと入っているイメージ。
<Auth>
コンポーネントがマウントされるとき、まず最初にuseEffect
内の処理が実行されます(第2引数に[]
を与えているため、componentDidMount()
と同じ役割)。先ほど定義したlistenAuthState
が実行され、ユーザー認証のリッスンが行われます。
if (!isSignedIn)
、すなわちユーザーが未認証のときは、<Auth>
コンポーネントは、空のHTMLタグ<></>
を返すようにしています。なぜなら、ユーザーが未認証のときはlistenAuthState
が実行された時点でdispatch(push("/signin"))
でサインイン画面にリダイレクトされることとなっているため、画面描画用のHTMLタグを用意する必要がないからです。
ユーザーが認証されていれば、<Auth>
コンポーネントはchildren
、すなわち子要素全体(アクセスしているアドレスが(/)
であれば、<Route exact path={"(/)?"} component={Home} />
)を描画する。
これでHomeコンポーネントが、ユーザー認証状態でしか描画できない((/)
にアクセスできない)状態になったはずです。
SignOut の実装
認証のリッスンについて動作確認をする前に、SignOut 機能も実装してしまいます。SignOutを行うためには、
- firebase.auth でサインアウト処理を実行する(=ブラウザの indexedDB からユーザー情報を削除する)
- Redux Store内の users state を、initialState に戻す
の二つの処理を行う必要があります。
実装のイメージとして、何らかのテンプレート内に配置する「SIGN OUT」ボタンのonClickイベントとして、上記処理が走る関数(=signOut()
)を埋め込むことになるはずです。
その関数は user state に関連するものなので、reducks/users/operations.js
に、まずsignOut()
を定義します。
signOut()
の中で、「firebase.auth でサインアウト処理を実行」→「Redux Store内の users state を、initialState に戻すアクションを実行」という流れにすることで、上記二つの処理を同時に行うことができるはずです。
以上を踏まえると、編集ファイルは以下の通り。
1. src/reducks/users/operations.js // signOut()関数を定義
2. src/reducks/users/actions.js // 定義済みの signOutAction を一部編集
3. src/reducks/users/reducers.js // case Action.SIGN_OUT を追加
4. src/templates/Home.jsx // signOut()関数を発火するボタンを追加
import { signInAction,signOutAction } from "./actions";
.
.
.
export const signOut = () => {
return async (dispatch) => {
auth.signOut()
.then(() => {
dispatch(signOutAction());
dispatch(push('/signin'));
})
}
}
auth.signOut()
は、firebase.authとしてのサインアウト処理を行うメソッドです。
その後、usersのアクションで定義するsignOutAction()
を発火することで、アプリ側のサインアウトも行われます。
.
.
.
export const SIGN_OUT = "SIGN_OUT";
export const signOutAction = () => {
return {
type: "SIGN_OUT",
payload: {
isSignedIn: false,
role: "", // 追記
uid: "",
username: ""
}
}
}
payloadの中身は、initialState での定義と全く同じです。これにより、Redux Store内の users state が、initialState に戻るようにしています。
import * as Actions from './actions'
import initialState from '../store/initialState'
export const UsersReducer = (state = initialState.users, action) => {
switch (action.type) {
case Actions.SIGN_IN:
return {
...state,
...action.payload
};
// 追記
case Actions.SIGN_OUT:
return {
...action.payload
};
// 追記ここまで
default:
return state
}
}
users の initialState と全く同じ payload を、スプレッド構文で展開しています。これが Store に渡ることで、state の中身が書き換わります。
import React from 'react';
import {getUserId, getUserName} from '../reducks/users/selectors';
import {useSelector, useDispatch} from 'react-redux'
import {signOut} from "../reducks/users/operations" // 追記
const Home = () => {
const dispatch = useDispatch() // 追記
const selector = useSelector(state => state);
const uid = getUserId(selector);
const username = getUserName(selector);
return (
<div>
<h2>Home</h2>
<p>ユーザーID:{uid}</p>
<p>ユーザー名:{username}</p>
<button onClick={() => dispatch(signOut())}>SIGN OUT</button> // 追記
</div>
);
};
export default Home
「SIGN OUT」ボタンをクリックすることで、operationで定義したsignOut()
が発火するようにしています。
ここまでくれば、実装は完了です!動作確認をしてみます。
新しいユーザーを一人新規作成するところから始めます。
localhost:3000/signup
「アカウントを登録する」をクリックすることで、ユーザーが登録され、画面がルートへリダイレクトされます。
localhost:3000/
先ほど配置した「SIGN OUT」ボタンがありますね。これをクリックすることで、サインアウト処理が行われ、かつ画面がサインイン画面にリダイレクトされます。
localhost:3000/signin
この状態でlocalhost:3000/
にアクセスしてみても、強制的にlocalhost:3000/signin
画面にリダイレクトされるはずです。
サインアウト処理が正常に行われており、かつ<Auth>
コンポーネントのラッピングによる認証のリッスンが機能していることが確認できます。
これで、認証のリッスンおよびサインアウト処理の実装が完了しました!
パスワードリセットの作成
認証周りの最後の機能として、パスワードリセット機能を作ります。
firebase.authが提供するsendPasswordResetEmail()
を利用することで、簡単にパスワードリセットを実装できます。
1. src/templates/Reset.jsx // パスワードリセット用のテンプレート
2. src/templates/index.js // Reset.jsxをエントリーポイントに追加
3. src/Router.jsx // Reset.jsxのルーティングを定義
4. src/reducks/users/operations.js // パスワードリセット用の関数を定義
5. src/templates/SignIn.jsx // SignUp, Resetへ遷移するリンクタグを追加
6. src/templates/SignUp.jsx // SignIp, Resetへ遷移するリンクタグを追加
5,6に関してはパスワードリセット機能の実装とは直接関係はないが、画面遷移がURL手打ちのままでも味気ないので、モックとしてそれっぽいリンクタグを配置しておく。
import React, {useState,useCallback} from "react";
import {PrimaryButton,TextInput} from "../components/UIkit"
import {resetPassword} from "../reducks/users/operations"
import {useDispatch} from "react-redux"
const Reset = () => {
const dispatch = useDispatch()
const [email,setEmail] = useState()
const inputEmail = useCallback((event) => {
setEmail(event.target.value)
},[setEmail])
return(
<div className="c-section-container">
<h2 className="u-text__headline u-text-center">パスワードリセット</h2>
<div className="module-spacer--medium" />
<TextInput
fullWidth={true} label={"メールアドレス"} multiline={false}
required={true} rows={1} value={email} type={"email"} onChange={inputEmail}
/>
<div className="module-spacer--medium" />
<div className="center">
<PrimaryButton
label={"パスワードリセット"}
onClick={() => dispatch(resetPassword(email))}
/>
</div>
</div>
)
}
export default Reset
- 基本構造は
SignIn.jsx
と同じ。パスワードリセットではメールアドレスしか使用しない点に注意。 - 「パスワードリセット」ボタンをクリックすることで、この後実装する
resetPassword()
が実行され、パスワードリセットの処理が走ることになる。
export {default as Home} from './Home'
export {default as Reset} from './Reset' //追記
export {default as SignIn} from './SignIn'
export {default as SignUp} from './SignUp'
- テンプレートを追加したら、忘れずにエントリーポイントに追記をする。
import React from 'react';
import {Route, Switch} from "react-router";
import {Home,Reset,SignIn,SignUp} from "./templates"; //追記
import Auth from "./Auth"
const Router = () => {
return (
<Switch>
<Route exact path={"/signup"} component={SignUp} />
<Route exact path={"/signin"} component={SignIn} />
<Route exact path={"/signin/reset"} component={Reset} /> //追記
<Auth>
<Route exact path={"(/)?"} component={Home} />
</Auth>
</Switch>
);
};
export default Router
- Resetテンプレートに対して、
(/signin/reset)
というURLをルーティング。 - Resetテンプレートはユーザー未認証状態でも表示させたいので、
<Auth>
ではラッピングしない。
.
.
.
export const resetPassword = (email) => {
return async (dispatch) => {
if (email === "") {
alert("必須項目が未入力です")
return false
} else {
auth.sendPasswordResetEmail(email)
.then(() => {
alert('入力されたアドレスにパスワードリセット用のメールを送りました。')
dispatch(push('/signin'))
}).catch(() => {
alert('パスワードリセットに失敗しました。通信環境を確認してください。')
})
}
}
}
.
.
.
- 「emailが空欄ではいけない」というバリデーションをかけている。
-
auth.sendPasswordResetEmail(email)
メソッドが実行されると、firebase側より、パスワードリセット用のメールが届く仕組みになっている。
.
.
.
<PrimaryButton
label={"サインイン"}
onClick={() => dispatch(signIn(email,password))}
/>
<div className="module-spacer--medium" />
<p onClick={() => dispatch(push('/signup'))}>アカウントをお持ちでない方はこちら</p> //追記
<p onClick={() => dispatch(push('/signin/reset'))}>パスワードをお忘れの方はこちら</p> //追記
.
.
.
- クリックすると
('/signup')
,('/signin/reset')
それぞれに遷移するリンクを、「サインイン」ボタン直下に配置 -
<div className="module-spacer--medium" />
はタグ同士の空欄を表現するタグ(styles.css
で定義)
.
.
.
<PrimaryButton
label={"アカウントを登録する"}
onClick={() => dispatch(signUp(username,email,password,confirmPassword))}
/>
<div className="module-spacer--medium" />
<p onClick={() => dispatch(push('/signin'))}>アカウントをお持ちの方はこちら</p> //追記
.
.
.
- クリックすると
('/signup')
に遷移するリンクを、「アカウントを登録する」ボタン直下に配置
これで、実装は一通り完了です。動作確認します。
実際にメールを受け取ることができるアドレスで、ユーザー登録をしてみます。
localhost:3000/signup
新規登録が成功すれば、ルートにリダイレクトします。
localhost:3000/
いったんサインアウトします。
localhost:3000/signin
「パスワードをお忘れの方はこちら」より、パスワードリセット画面へ飛びます。
localhost:3000/signin/reset
メールアドレスを入力して、「パスワードリセット」ボタンを押すと、
先ほど定義した通りのalert文が出ていますね。「OK」を押すと、サインイン画面にリダイレクトされます。
実際にメールボックスを確認すると、firebase より、パスワードリセット用のメールが届いているはずです。
URLをクリックすると、firebaseが用意しているパスワードリセット用の画面に飛びます。
新しいパスワードをSAVEします。
再度、Reactアプリのサインイン画面から、新しいパスワードを用いてサインインしてみます。
localhost:3000/signin
localhost:3000/
新しいパスワードでのサインインができました!
おわり
要点をまとめると、
- 認証状態の永続化のため、
認証のリッスン機能
を作成する - 認証のリッスン機能を持たせた
<Auth>
コンポーネントで、リッスン対象をラッピングする - パスワードリセット機能は、firebase.authの機能で簡単に実装できる。
今回はここまで!講座一回あたりの内容がボリューミーになってきたので、基本1講座1記事の単位で投稿していくことになりそうです。