Merged PR 742: ログアウトせずに認証切れまで待った後にTOPページからログインしようとするとエラーが発生する

## 概要
[Task3680: ログアウトせずに認証切れまで待った後にTOPページからログインしようとするとエラーが発生する](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3680)

- 現象
  - ブラウザバック対策のコードで、アクセストークンの寿命を意識せずにログイン後画面に遷移していたため、リフレッシュトークンとアクセストークンの再発行がSkipされた上で寿命が切れたトークンを使うような状態でログイン後画面に遷移してしまっていた
- 対応
  - /login, /authで、アクセストークンチェック→IdTokenを使ったログイン中プロセスかチェック の順番を変更
    - 有効なアクセストークンがあったとしても、再ログインをしてるのであればリフレッシュトークン等の再発行を実施する形に変更

## レビューポイント
- Bugの現象が解消する以外の挙動の変更が発生してしまわないか
  - 特にブラウザバック等
- 実装方法で問題がありそうな部分はないか
  - 不要なTokenのクリア等はないか

## UIの変更
- なし

## 動作確認状況
- ローカルで確認

## 補足
- **可能であればローカルで挙動を確認していただきたいです**
  - .env.local の `ACCESS_TOKEN_LIFETIME_WEB` を60(=1分)とかにすれば確認はしやすいはず
This commit is contained in:
湯本 開 2024-02-09 08:12:26 +00:00
parent 0d0f624a3f
commit 7ca4249f04
6 changed files with 53 additions and 74 deletions

View File

@ -1,8 +1,6 @@
import AppRouter from "AppRouter";
import { BrowserRouter } from "react-router-dom";
import { PublicClientApplication } from "@azure/msal-browser";
import { MsalProvider, useMsal } from "@azure/msal-react";
import { msalConfig } from "common/msalConfig";
import { useMsal } from "@azure/msal-react";
import { useEffect, useLayoutEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import globalAxios, { AxiosError, AxiosResponse } from "axios";
@ -19,7 +17,6 @@ const App = (): JSX.Element => {
const { instance } = useMsal();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [t, i18n] = useTranslation();
const pca = new PublicClientApplication(msalConfig);
useEffect(() => {
const id = globalAxios.interceptors.response.use(
(response: AxiosResponse) => response,
@ -70,11 +67,9 @@ const App = (): JSX.Element => {
dispatch(closeSnackbar());
}}
/>
<MsalProvider instance={pca}>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</MsalProvider>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</>
);
};

View File

@ -76,3 +76,16 @@ export const getIdTokenFromLocalStorage = (
}
return null;
};
// JWTが有効期限切れかどうかを判定する
export const isTokenExpired = (token: string | null): boolean => {
if (token == null) {
return true;
}
const tokenObject = JSON.parse(atob(token.split(".")[1]));
if (isToken(tokenObject)) {
const now = Math.floor(Date.now() / 1000);
return tokenObject.exp < now;
}
return true;
};

View File

@ -3,18 +3,25 @@ import React from "react";
import { createRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { Provider } from "react-redux";
import { PublicClientApplication } from "@azure/msal-browser";
import { msalConfig } from "common/msalConfig";
import { MsalProvider } from "@azure/msal-react";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import i18n from "./i18n";
const pca = new PublicClientApplication(msalConfig);
const container = document.getElementById("root");
if (container) {
const root = createRoot(container);
root.render(
<React.StrictMode>
<Provider store={store}>
<I18nextProvider i18n={i18n} />
<App />
<MsalProvider instance={pca}>
<I18nextProvider i18n={i18n} />
<App />
</MsalProvider>
</Provider>
</React.StrictMode>
);

View File

@ -10,14 +10,6 @@ import {
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
clearToken,
isAdminUser,
isApproveTier,
isStandardUser,
loadAccessToken,
} from "features/auth";
import { TIERS } from "components/auth/constants";
const AuthPage: React.FC = (): JSX.Element => {
const { instance } = useMsal();
@ -34,38 +26,7 @@ const AuthPage: React.FC = (): JSX.Element => {
(async () => {
try {
// ログイン済みの場合、ログイン後の遷移先を決定する
if (loadAccessToken()) {
// 第一~第四階層の管理者はライセンス画面へ遷移
if (
isApproveTier([
TIERS.TIER1,
TIERS.TIER2,
TIERS.TIER3,
TIERS.TIER4,
]) &&
isAdminUser()
) {
navigate("/license");
return;
}
// 第五階層の管理者はユーザー画面へ遷移
if (isApproveTier([TIERS.TIER5]) && isAdminUser()) {
navigate("/user");
return;
}
// 一般ユーザーはdictationPageへ遷移
if (isStandardUser()) {
navigate("/dictations");
return;
}
// それ以外は認証エラー画面へ遷移
instance.logoutRedirect({
postLogoutRedirectUri: "/AuthError",
});
clearToken();
return;
}
// idTokenが有効セットされているかを確認する
const loginResult = await instance.handleRedirectPromise();
if (loginResult && loginResult.account) {
const { homeAccountId, idTokenClaims } = loginResult.account;

View File

@ -1,8 +1,10 @@
import React from "react";
import { Link } from "react-router-dom";
export const AuthErrorPage = (): JSX.Element => (
<div>
<p></p>
<p>login failed</p>
<br />
<Link to="/">return to TopPage</Link>
</div>
);

View File

@ -1,6 +1,6 @@
import { useMsal } from "@azure/msal-react";
import { AppDispatch } from "app/store";
import { isIdToken } from "common/token";
import { isIdToken, isTokenExpired } from "common/token";
import {
clearToken,
isAdminUser,
@ -52,10 +52,10 @@ const LoginPage: React.FC = (): JSX.Element => {
instance.logoutRedirect({
postLogoutRedirectUri: "/AuthError",
});
clearToken();
}, [instance, navigate]);
dispatch(clearToken());
}, [instance, navigate, dispatch]);
const tokenSet = useCallback(
const tokenSetAndNavigate = useCallback(
async (idToken: string) => {
// ログイン処理呼び出し
const { meta, payload } = await dispatch(loginAsync({ idToken }));
@ -96,31 +96,32 @@ const LoginPage: React.FC = (): JSX.Element => {
useEffect(() => {
// idTokenStringがあるか⇒認証中
// accessTokenがある場合⇒ログイン済み
// どちらもなければ直打ち
// accessTokenがある場合⇒ログイン済みなのにブラウザバックでログイン画面に戻ってきた場合
// どちらもなければURL直打ち
(async () => {
if (loadAccessToken()) {
navigateToLoginedPage();
return;
// ローカルストレージにidTokenがある場合は取得する
let idTokenString: string | null = null;
if (localStorageKeyforIdToken !== null) {
idTokenString = localStorage.getItem(localStorageKeyforIdToken);
}
// AADB2Cのログイン画面とLoginPageを経由していない場合はトップページに遷移する
if (!localStorageKeyforIdToken) {
navigate("/");
return;
}
const idTokenString = localStorage.getItem(localStorageKeyforIdToken);
// idTokenがない(=正常なログインプロセス中でない)場合は有効なアクセストークンを所持しているか確認し、
// 有効であればログイン画面に遷移 or 無効であればトップページに遷移
if (idTokenString === null) {
navigate("/");
const token = loadAccessToken();
// アクセストークンがない or 有効期限切れ場合はトップページに遷移
if (isTokenExpired(token)) {
navigate("/");
} else {
// 有効なアクセストークンがある場合はログイン画面に遷移
navigateToLoginedPage();
}
return;
}
if (idTokenString) {
const idTokenObject = JSON.parse(idTokenString);
if (isIdToken(idTokenObject)) {
await tokenSet(idTokenObject.secret);
}
const idTokenObject = JSON.parse(idTokenString);
if (isIdToken(idTokenObject)) {
await tokenSetAndNavigate(idTokenObject.secret);
}
})();
// 画面描画後のみ実行するため引数を設定しない