Chapter 08

Auth:パスワード初期化処理

masalib
masalib
2020.12.24に更新

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

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

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

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

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

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

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

環境変数にメールで使うドメインを設定

パスワードの処理化のメールは完了したページにリンクを設定できます。
ローカル環境と本番では違うので環境変数にドメインを設定します

.env.development.local
REACT_APP_APIKEY=XXXXXXXXXXXXXXXXXX
REACT_APP_AUTHDOMAIN=learn-firebase-masalib.firebaseapp.com
REACT_APP_DATABASEURL=https://learn-firebase-masalib.firebaseio.com
REACT_APP_PROJECT_ID=learn-firebase-masalib
REACT_APP_STORAGE_BUCKET=learn-firebase-masalib.appspot.com
REACT_APP_MESSAGING_SENDER_ID=XXXXXXXXXXXXXXXXXX
REACT_APP_APP_ID=XXXXXXXXXXXXXXXXXX
REACT_APP_MEASUREMENT_ID=XXXXXXXXXXXXXXXXXX
+ REACT_APP_MAIL_URL=http://localhost:3000/

パスワード初期化処理

contextに処理をつくって関数を共有させます

/src/contexts/AuthContext.js
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 resetPassword(email) {
+     // .env use case      url: process.env.REACT_APP_MAIL_URL + '?email=' + email,
+     // local dev case     url: "http://localhost:3000/?email=" + email,
+     // product case     url: "https://you-domain/?email=' + email,
+     const actionCodeSettings = {
+       url: "https://g25ew.csb.app/"
+     };
+     return auth.sendPasswordResetEmail(email, actionCodeSettings);
+   }



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

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

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

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

ログインのプログラムをコピーして一部削除すればフォームとしてはできます。

プログラム全文(長文なので注意)
/src/components/ForgotPassword.js
import React, { useReducer, useEffect, useState } 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, useHistory } from "react-router-dom";

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

//state type
type State = {
  email: string,
-   password: string,
-   passwordconfirm: string,
  isButtonDisabled: boolean,
  helperText: string,
  isError: boolean
};

const initialState: State = {
  email: "",
-   password: "",
-   passwordconfirm: "",
  isButtonDisabled: true,
  helperText: "",
  isError: false
};

type Action =
  | { type: "setEmail", payload: string }
  | { type: "setIsButtonDisabled", payload: boolean }
+   | { type: "forgotpasswordSuccess", payload: string }
+   | { type: "forgotpasswordFailed", payload: string }
-   | { type: "setPassword", payload: string }
-   | { type: "setPasswordConfirm", payload: string }
-   | { type: "loginSuccess", payload: string }
-   | { type: "loginFailed", payload: string }
  | { type: "setIsError", payload: boolean };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "setEmail":
      return {
        ...state,
        email: action.payload
      };
-     case "setPassword":
-       return {
-         ...state,
-         password: action.payload
-       };
-     case "setPasswordConfirm":
-       return {
-         ...state,
-         passwordconfirm: action.payload
-       };
    case "setIsButtonDisabled":
      return {
        ...state,
        isButtonDisabled: action.payload
      };
-     case "loginSuccess":
+     case "forgotpasswordSuccess":
      return {
        ...state,
        helperText: action.payload,
        isError: false
      };
-     case "loginFailed":
+     case "forgotpasswordFailed":
      return {
        ...state,
        helperText: action.payload,
        isError: true
      };
    case "setIsError":
      return {
        ...state,
        isError: action.payload
      };
    default:
      return state;
  }
};

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


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

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

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

-       await login(state.email, state.password);
+       await resetPassword(state.email);
      dispatch({
-         type: "loginSuccess",
-         payload: "Login Successfully"
+         type: "forgotpasswordSuccess",
+         payload: "ForgotPassword Successfully"
      });

      //ForgotPassword ボタンの有効化
      dispatch({
        type: "setIsButtonDisabled",
        payload: false
      });
-       setSuccessMessage("ログインに成功しました");
-       history.push("/dashboard");
+       setSuccessMessage("パスワードを初期化しました。");

    } catch (e) {
      console.log(e);
      //エラーのメッセージの表示
      switch (e.code) {
        case "auth/network-request-failed":
          setError(
            "通信がエラーになったのか、またはタイムアウトになりました。通信環境がいい所で再度やり直してください。"
          );
          break;
        case "auth/invalid-email":
          setError("メールアドレスまたはパスワードが正しくありません");
          break;
        case "auth/wrong-password":
          setError("メールアドレスまたはパスワードが正しくありません");
          break;
-         case "auth/weak-password": //バリデーションでいかないようにするので、基本的にはこのコードはこない
-           setError("パスワードが短すぎます。6文字以上を入力してください。");
-           break;
        case "auth/user-disabled":
          setError("入力されたメールアドレスは無効(BAN)になっています。");
          break;
        default:
          //想定外
          setError(
-             "アカウントの作成に失敗しました。通信環境がいい所で再度やり直してください。"
+             "処理に失敗しました。通信環境がいい所で再度やり直してください。"
          );
      }
      //sing up ボタンの有効化
      dispatch({
        type: "setIsButtonDisabled",
        payload: false
      });
    }
  }

  const handleKeyPress = (event: React.KeyboardEvent) => {
    if (event.keyCode === 13 || event.which === 13) {
      if (!state.isButtonDisabled) {
        handleKeyPresstrigger();
        if (errors) {
          //errorメッセージを表示する
        } else {
-           handleLogin();
+           handleForgotPassword();
        }
      }
    }
  };

  async function handleKeyPresstrigger() {
    const result = await trigger();
    return result;
  }

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

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

  return (
    <form className={classes.container} noValidate autoComplete="off">
      <Card className={classes.card}>
-         <CardHeader className={classes.header} title="Login" />
+         <CardHeader className={classes.header} title="ForgotPassword" />
        <CardContent>
          <div>
            {error && <div variant="danger">{error}</div>}
            {successMessage && <div variant="danger">{successMessage}</div>}
            <TextField
              error={state.isError}
              fullWidth
              id="email"
              name="email"
              type="email"
              label="Email"
              placeholder="Email"
              margin="normal"
              onChange={handleEmailChange}
              onKeyPress={handleKeyPress}
              inputRef={register({
                pattern: /^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@{1}[A-Za-z0-9_.-]{1,}\.[A-Za-z0-9]{1,}$/
              })}
            />
            {errors.email?.type === "pattern" && (
              <div style={{ color: "red" }}>
                メールアドレスの形式で入力されていません
              </div>
            )}
          </div>
-             <TextField
-               error={state.isError}
-               fullWidth
-               id="password"
-               name="password"
-               type="password"
-               label="Password"
-               placeholder="Password"
-               margin="normal"
-               helperText={state.helperText}
-               onChange={handlePasswordChange}
-               onKeyPress={handleKeyPress}
-               inputRef={register({ required: true, minLength: 6 })}
-             />
-             {errors.password?.type === "minLength" && (
-               <div style={{ color: "red" }}>
-                 パスワードは6文字以上で入力してください
-               </div>
-             )}

          もしアカウントがないなら<Link to="/signup">こちら</Link>
          からアカウントを作成してください。
        </CardContent>
        <CardActions>
          <Button
            variant="contained"
            size="large"
            color="secondary"
+             className={classes.forgotpasswordBtn}
+             onClick={handleSubmit(handleForgotPassword)}
+             disabled={state.isButtonDisabled}
+           >
+             ForgotPassword
+           </Button>
-             className={classes.loginBtn}
-             onClick={handleSubmit(handleLogin)}
-             disabled={state.isButtonDisabled}
-           >
-             Login
-           </Button>

        </CardActions>
      </Card>
    </form>
  );
};

export default ForgotPassword;

画面のソース解説

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

上記で作った関数を読んでいるでけです。成功した場合に画面にメッセージを表示させています。サイトのポリシーにもよりますが、メールアドレス有効化状態でメール送信はさけた方がいいです。Google先生からスパムメールの送信と思われてしまうからです。どうしてもわからない場合は、問い合わせからおこなうのが普通だと思います。
エラーハンドリングはログインと同様に通信エラーはゆるいです。BANされたという特殊なパターンも考慮して作っています

パスワードを忘れた処理をRouteにいれる

作ったパスワードを忘れた処理を追加したいと思います

/src/components/App.js
import React from "react";
import Signup from "./Signup";
import Home from "./Home";
import Login from "./Login";
import Dashboard from "./Dashboard";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import { AuthProvider } from "../contexts/AuthContext";
import AuthFirebaseRoute from "./AuthFirebaseRoute"; //ログイン認証
+ import ForgotPassword from "./ForgotPassword";

export default function App() {
  return (
    <Router>
      <AuthProvider>
        <Switch>
          <Route path="/signup" component={Signup} />
+          <Route path="/forgotPassword" component={ForgotPassword} />
          <Route exact path="/" component={Home} />

          <Route path="/login" component={Login} />
          <AuthFirebaseRoute path="/dashboard" component={Dashboard} />
        </Switch>
      </AuthProvider>
    </Router>
  );
}

結果

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

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

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

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

なお初期設定だとproduct-xxxxxみたいな形のサイト名になるので
修正した方がいいです。

終了時点のソース

  • この画面でもユーザーを作成とログインができます。
  • React Developer Toolsならユーザーが作成された事が確認できます
  • Codesandboxは環境変数が使えないので直接記載しています。

この時点のソースです