masalibの日記

システム開発、運用と猫の写真ブログです

React Firebase入門 パスワード初期化処理

認証システムでは当たり前のようにある機能のパスワード初期化処理を作りました。

React Firebase入門シリーズ
1-1・ React Firebase入門 初期設定とsignup - masalibの日記
1-2・ 「react-hook-form」を入れてみた - masalibの日記
1-3・ React Firebase入門 signupの通信エラー対応 - masalibの日記
2・ React Firebase入門 ログイン処理 - masalibの日記
3・ React Firebase入門 ログイン認証とログアウト処理 - masalibの日記
4・React Firebase入門 パスワード初期化処理 今ここ

パスワードの初期化機能とは

  1. パスワードの初期化
  2. userにパスワードを再設定するためのURLをメールで送付
  3. パスワードの再設定ページでパスワードを設定する

この3つで再設定ができます

メールのテンプレートの変更

パスワード初期化のメールのテンプレートはFirebase側が用意してくれています。ただデフォルト設定は英語なので日本語にします

  1. Firebaseにログインして該当のプロジェクトを選択する。
  2. メニューのAuthenticationのTemplatesを選択する
  3. テンプレート言語設定を英語から日本語に変える

パスワード初期化処理

contextに処理をつくってその関数を共有させるだけです。

ソース

全文はたぶん見なくても大丈夫かと思う。

クリックすると展開されます(長文なので注意)

import React, { useContext, useState, useEffect } from "react"
import { auth } from "../firebase"

const AuthContext = React.createContext()

export function useAuth() {
    return useContext(AuthContext)
}

export function AuthProvider({ children }) {
    const [currentUser, setCurrentUser] = useState()
    const [loading, setLoading] = useState(true)

    function signup(email, password) {
        return auth.createUserWithEmailAndPassword(email, password)
    }

    function login(email, password) {
      return auth.signInWithEmailAndPassword(email, password)
    }

    function logout() {
      return auth.signOut()
    }

    function sendEmailVerification(){
      const actionCodeSettings = {
        url: 'http://localhost:3000/dashboard' ,
      }    
      return currentUser.sendEmailVerification(actionCodeSettings)
    }


    function resetPassword(email) {
      //https://firebase.google.com/docs/auth/web/passing-state-in-email-actions
      const actionCodeSettings = {
        url: 'http://localhost:3000/?email=' + email,
        /*
        iOS: {
          bundleId: 'com.example.ios'
        },
        android: {
          packageName: 'com.example.android',
          installApp: true,
          minimumVersion: '12'
        },
        handleCodeInApp: true,
        // When multiple custom dynamic link domains are defined, specify which
        // one to use.
        dynamicLinkDomain: "example.page.link"
        */
      };
      return auth.sendPasswordResetEmail(email,actionCodeSettings)
    }

    const value = {
        currentUser,
        signup,
        login,
        logout,
        resetPassword,
        sendEmailVerification
    }

    useEffect(() => {
        // Firebase Authのメソッド。ログイン状態が変化すると呼び出される
        auth.onAuthStateChanged(user => {
          setCurrentUser(user);
          setLoading(false)
        });
      }, []);

    return (
        <AuthContext.Provider value={value}>
           {!loading && children}
        </AuthContext.Provider>
    )
}

ソースの解説

追加したのはresetPasswordという関数です。

function resetPassword(email) {
  //https://firebase.google.com/docs/auth/web/passing-state-in-email-actions
  const actionCodeSettings = {
    url: 'http://localhost:3000/?email=' + email,
    /*
    iOS: {
      bundleId: 'com.example.ios'
    },
    android: {
      packageName: 'com.example.android',
      installApp: true,
      minimumVersion: '12'
    },
    handleCodeInApp: true,
    // When multiple custom dynamic link domains are defined, specify which
    // one to use.
    dynamicLinkDomain: "example.page.link"
    */
  };
  return auth.sendPasswordResetEmail(email,actionCodeSettings)
}
//firebase.auth.Auth.sendPasswordResetEmail(メールアドレス(必須), アクションコード(なくてもOK))
firebase.auth.Auth.sendPasswordResetEmail(email: string, actionCodeSettings?: firebase.auth.ActionCodeSettings | null | undefined): Promise<void>)

アクションコードの設定はなくてもOKですが、メールの変更をした後に何もできない状態が発生するのでほぼ必須です。 指定した場合、パスワードリセットリンクの「continueUrl」パラメータにstate/continue URL(完了したあとに動くページの事)が設定されます。 コメントアウトしていますが、アプリでつくているアプリでもどるためのリンクもつける事ができます。 またアクションURLはFirebaseのコンソールで開発者によってホワイトリストに登録されているドメインに属する必要があります。(デプロイした時は注意が必要)
もし違うドメインの場合はエラーになります(スロー)。

モバイルアプリのリダイレクトは、開発者がFirebase Dynamic Linksの条件を設定し、受け入れた場合にのみ適用されます。 Androidパッケージ名とiOSバンドルIDは、使用しているFirebase Authプロジェクトで設定されている場合にのみ適用されます。


共通するためのvalueの値を修正

const value = {
    currentUser,
    signup,
    login,
    logout,
+   resetPassword
}

パスワード初期化のフォーム

メールアドレスだけがある簡単なフォームです。

画面のソース

クリックすると展開されます(長文なので注意)

import React, { useState,useReducer, useEffect } from "react";
import { useForm } from "react-hook-form";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent";
import CardActions from "@material-ui/core/CardActions";
import CardHeader from "@material-ui/core/CardHeader";
import Button from "@material-ui/core/Button";
import { useAuth } from "../contexts/AuthContext"
import { Link } from "react-router-dom"


const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
      display: "flex",
      flexWrap: "wrap",
      width: 400,
      margin: `${theme.spacing(0)} auto`
    },
    forgotBtn: {
      marginTop: theme.spacing(2),
      flexGrow: 1
    },
    header: {
      textAlign: "center",
      background: "#212121",
      color: "#fff"
    },
    card: {
      marginTop: theme.spacing(10)
    }
  })
);

//state type
type State = {
  username: string,
  isButtonDisabled: boolean,
  helperText: string,
  isError: boolean
};

const initialState: State = {
  username: "",
  isButtonDisabled: true,
  helperText: "",
  isError: false
};

type Action =
  | { type: "setUsername", payload: string }
  | { type: "setIsButtonDisabled", payload: boolean }
  | { type: "setIsError", payload: boolean };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "setUsername":
      return {
        ...state,
        username: action.payload
      };
    case "setIsButtonDisabled":
      return {
        ...state,
        isButtonDisabled: action.payload
      };
    case "setIsError":
      return {
        ...state,
        isError: action.payload
      };
    default:
       return state;
  }
};

const ForgotPassword = () => {
    const classes = useStyles();
    const [state, dispatch] = useReducer(reducer, initialState);
    const [error, setError] = useState("")
    const [successMessage, setSuccessMessage] = useState("")
    const { resetPassword } = useAuth()
    const { register, handleSubmit, errors  } = useForm();

    useEffect(() => {
      if (state.username.trim() ){
          //trigger();
          dispatch({
            type: "setIsButtonDisabled",
            payload: false
          });
      } else {
          //clearErrors()
          dispatch({
            type: "setIsButtonDisabled",
            payload: true
          });
      }
    }, [state.username]);

    async function handleForgotPassword (data) {  //react-hook-formを導入したためevent -> dataに変更
        //event.preventDefault()      //react-hook-formを導入したため削除

        try {
            setError("")
            setSuccessMessage("")
            //ボタンの無効化
            dispatch({
                type: "setIsButtonDisabled",
                payload: true
            });

            //FirebaseのメールテンプレートがJP(日本語)になっている事が前提です。
            //メールテンプレートで

            await resetPassword(state.username)
            setSuccessMessage("パスワードを初期化しました。")

            dispatch({
                type: "setIsButtonDisabled",
                payload: false
            });

        } catch (e){
            //エラーのメッセージの表示
            console.log(e)
            switch (e.code) {
                case "auth/network-request-failed":
                    setError("通信がエラーになったのか、またはタイムアウトになりました。通信環境がいい所で再度やり直してください。");
                    break;
                case "auth/invalid-email": 
                    setError("メールアドレスが間違えています。");
                    break;
                case "auth/user-not-found":    
                    setError("メールアドレスが間違えています。");
                    break;
                case "auth/user-disabled": 
                    setError("入力されたメールアドレスは無効(BAN)になっています。");
                    break;                    
                default:  //想定外
                    setError("処理に失敗しました。通信環境がいい所で再度やり直してください。");
            }
            //ボタンの有効化
            dispatch({
                type: "setIsButtonDisabled",
                payload: false
            });
            
        }
    };

  const handleUsernameChange: React.ChangeEventHandler<HTMLInputElement> = (
    event
  ) => {
    dispatch({
      type: "setUsername",
      payload: event.target.value
    });
  };

  return (
    <form className={classes.container} noValidate autoComplete="off">
      <Card className={classes.card}>
        <CardHeader className={classes.header} title="ForgotPassword" />
        <CardContent>
        <div>

            <div>登録されてメールアドレスのパスワードを初期化します。メールアドレスを入力してください。</div>
            {error && <div style={{ color: "red" }}>{error}</div>}
            {successMessage && <div variant="danger">{successMessage}</div>}
            <TextField
                error={state.isError}
                fullWidth
                id="username"
                name="username"
                type="email"
                label="Username"
                placeholder="Username"
                margin="normal"
                onChange={handleUsernameChange}
                inputRef={register({pattern: /^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@{1}[A-Za-z0-9_.-]{1,}\.[A-Za-z0-9]{1,}$/ })}
            />
            {errors.username?.type === "pattern" &&
            <div style={{ color: "red" }}>メールアドレスの形式で入力されていません</div>}
          </div>
          もしアカウントがないなら<Link to="/signup">こちら</Link>からアカウントを作成してください
        </CardContent>
        <CardActions>
          <Button
            variant="contained"
            size="large"
            color="secondary"
            className={classes.forgotBtn}
            onClick={handleSubmit(handleForgotPassword)}
            disabled={state.isButtonDisabled}
          >
            Reset Password
          </Button>
        </CardActions>
      </Card>
    </form>
  );
};

export default ForgotPassword;

画面のソース解説

await resetPassword(state.username)
setSuccessMessage("パスワードを初期化しました。")

上記で作った関数を読んでいるでけです。成功した場合に画面にメッセージを表示させています。普通に考えてメールアドレス有効化していないのは送ったらあかんような気がするけどハンドリングできない。Firebase側にまかせる感じになっています。ログインと同様に通信エラーはゆるいです。BANされたという特殊なパターンも考慮して作っています

switch (e.code) {
    case "auth/network-request-failed":
        setError("通信がエラーになったのか、またはタイムアウトになりました。通信環境がいい所で再度やり直してください。");
        break;
    case "auth/invalid-email": 
        setError("メールアドレスが間違えています。");
        break;
    case "auth/user-not-found":    
        setError("メールアドレスが間違えています。");
        break;
    case "auth/user-disabled": 
        setError("入力されたメールアドレスは無効(BAN)になっています。");
        break;        
    default:  //想定外
        setError("処理に失敗しました。通信環境がいい所で再度やり直してください。");
}

結果

  1. メールアドレスを入力する
  2. メールが届く
  3. パスワード入力画面が表示される
  4. 完了画面が表示される
  5. サイトのトップに戻ってくる

メールのリンクはワンタイムキーがあるのでセキュリティ的には安全。

continueUrl=http%3A%2F%2Flocalhost%3A3000%2F%3Femail%3Dmasalib%40gmail.com

が変更が完了した時に画面遷移するURLです。

感想

  • メールのテンプレートは直さないといけないな〜と思う。
  • パスワード変更画面は超シンプル。修正ができないみたい(やり方がわからなかった)。
  • デプロイと開発時でアクションURLを切り替えるようにしないといけないけどできていない。