Merged PR 5: タスク 1471: 画面実装(トークン系)

## 概要
[Task: 1471](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation/_sprints/taskboard/OMDSDictation%20%E3%83%81%E3%83%BC%E3%83%A0/OMDSDictation/%E3%82%B9%E3%83%97%E3%83%AA%E3%83%B3%E3%83%88%203-2?workitem=1471)

- アクセストークンの自動更新処理を実装
  - UpdateTokenTimerで定期実行を行う
- 未ログインまたはトークンが期限切れの状態で、ログイン後の画面にアクセスした場合、Topページにリダイレクトする処理を実装
  - RouteAuthGuard.tsx
- APIからのレスポンスが401だった時にTopページにリダイレクトする処理を実装
  - App.tsx

## レビューポイント
- 今の実装だとトークンの自動更新に失敗した場合、画面上では何も起こらないようにになっている
  - 更新が失敗し続け、アクセストークンが切れた段階でRouteAuthGuardではじかれてTopへリダイレクトする
- トークンの期限を確認する間隔を3分にしているが問題なさそうか

## UIの変更
-

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

## 補足
This commit is contained in:
saito.k 2023-03-08 00:57:55 +00:00
parent a1ddc64d2b
commit 4ce2bbf823
22 changed files with 321 additions and 39 deletions

View File

@ -23,7 +23,9 @@
"axios": "^0.27.2",
"eslint-plugin-prefer-arrow": "^1.2.3",
"i18next": "^21.10.0",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"luxon": "^3.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-google-recaptcha-v3": "^1.10.0",
@ -42,6 +44,7 @@
"@mdx-js/react": "^2.1.2",
"@openapitools/openapi-generator-cli": "^2.5.1",
"@types/lodash": "^4.14.191",
"@types/luxon": "^3.2.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/redux-mock-store": "^1.0.3",
@ -65,6 +68,9 @@
"vite": "^2.9.9",
"vite-plugin-env-compatible": "^1.1.1",
"vite-tsconfig-paths": "^3.5.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@ampproject/remapping": {
@ -1098,6 +1104,12 @@
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
"dev": true
},
"node_modules/@types/luxon": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz",
"integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==",
"dev": true
},
"node_modules/@types/mdx": {
"version": "2.0.2",
"dev": true,
@ -4016,6 +4028,11 @@
"node": ">=4.0"
}
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/language-subtag-registry": {
"version": "0.3.22",
"dev": true,
@ -4218,6 +4235,14 @@
"node": ">=10"
}
},
"node_modules/luxon": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz",
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==",
"engines": {
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.4.4",
"license": "WTFPL",
@ -6873,6 +6898,12 @@
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
"dev": true
},
"@types/luxon": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz",
"integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==",
"dev": true
},
"@types/mdx": {
"version": "2.0.2",
"dev": true
@ -8732,6 +8763,11 @@
"object.assign": "^4.1.2"
}
},
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"language-subtag-registry": {
"version": "0.3.22",
"dev": true
@ -8876,6 +8912,11 @@
"yallist": "^4.0.0"
}
},
"luxon": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz",
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg=="
},
"lz-string": {
"version": "1.4.4"
},

View File

@ -32,7 +32,9 @@
"axios": "^0.27.2",
"eslint-plugin-prefer-arrow": "^1.2.3",
"i18next": "^21.10.0",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"luxon": "^3.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-google-recaptcha-v3": "^1.10.0",
@ -51,6 +53,7 @@
"@mdx-js/react": "^2.1.2",
"@openapitools/openapi-generator-cli": "^2.5.1",
"@types/lodash": "^4.14.191",
"@types/luxon": "^3.2.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/redux-mock-store": "^1.0.3",

View File

@ -2,11 +2,35 @@ import AppRouter from "AppRouter";
import { BrowserRouter } from "react-router-dom";
import { GlobalStyle } from "styles/GlobalStyle";
import { PublicClientApplication } from "@azure/msal-browser";
import { MsalProvider } from "@azure/msal-react";
import { msalConfig } from "common/auth/msalConfig";
import { MsalProvider, useMsal } from "@azure/msal-react";
import { msalConfig } from "common/msalConfig";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import globalAxios, { AxiosError, AxiosResponse } from "axios";
import { clearToken } from "features/auth";
const App = (): JSX.Element => {
const dispatch = useDispatch();
const { instance } = useMsal();
const pca = new PublicClientApplication(msalConfig);
useEffect(() => {
const id = globalAxios.interceptors.response.use(
(response: AxiosResponse) => response,
(e: AxiosError) => {
if (e?.response?.status === 401) {
dispatch(clearToken());
instance.logout({
postLogoutRedirectUri: "/",
});
}
return Promise.reject(e);
}
);
const cleanup = () => {
globalAxios.interceptors.response.eject(id);
};
return cleanup;
}, [dispatch, instance]);
return (
<>

View File

@ -4,14 +4,18 @@ import TopPage from "pages/TopPage";
import LoginPage from "pages/LoginPage";
import SamplePage from "pages/SamplePage";
import { AuthErrorPage } from "pages/ErrorPage";
import { RouteAuthGuard } from "components/auth/routeAuthGuard";
const AppRouter: React.FC = () => (
<BaseDiv>
<Routes>
<Route path="/" element={<TopPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/XXX" element={<SamplePage />} />
<Route path="/AuthError" element={<AuthErrorPage />} />
<Route
path="/XXX"
element={<RouteAuthGuard component={<SamplePage />} />}
/>
</Routes>
</BaseDiv>
);

View File

@ -0,0 +1,19 @@
import jwt_decode from "jwt-decode";
import { isToken, Token } from "./token";
export const decodeToken = (jwt: string): Token | null => {
if (jwt === null) {
return null;
}
try {
const token = jwt_decode(jwt);
// JWTのpayloadを復号したオブジェクトが、Token interfaceを実装していなかった場合はnull
if (!isToken(token)) {
return null;
}
return token;
} catch {
return null;
}
};

View File

@ -0,0 +1,27 @@
// トークンの型やtypeGuardの関数を配置するファイル
// TODO トークンの型は仮
export interface Token {
userId: string;
scope: string;
exp: number;
iat: number;
}
// Type Guard
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isToken = (arg: any): arg is Token => {
if (arg.userId === undefined) {
return false;
}
if (arg.scope === undefined) {
return false;
}
if (arg.exp === undefined) {
return false;
}
if (arg.iat === undefined) {
return false;
}
return true;
};

View File

@ -0,0 +1,22 @@
import { useRef, useEffect } from "react";
export const useInterval = async (
callback: () => Promise<void>,
interval: number
) => {
const callbackRef = useRef<() => Promise<void>>(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
const updateToken = async () => {
await callbackRef.current();
};
const id = setInterval(updateToken, interval);
return () => {
clearInterval(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};

View File

@ -0,0 +1,41 @@
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
isAuthenticatedSelector,
isTokenExpired,
} from "features/auth/selectors";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "app/store";
import { clearToken } from "features/auth";
import { useMsal } from "@azure/msal-react";
type RouteAuthGuardProps = {
component: JSX.Element;
};
export const RouteAuthGuard = (props: RouteAuthGuardProps) => {
const isAuth = useSelector(isAuthenticatedSelector);
const isExpired = useSelector(isTokenExpired);
const dispatch: AppDispatch = useDispatch();
const navigate = useNavigate();
const { instance } = useMsal();
const { component } = props;
// トークンの有効期限が切れていたらTopへリダイレクト
useEffect(() => {
if (!isAuth) {
navigate("/");
} else if (isExpired) {
dispatch(clearToken());
// B2Cからもログアウトする
instance.logout({
postLogoutRedirectUri: "/",
});
}
}, [isAuth, isExpired, dispatch, navigate, instance]);
if (!isAuth || isExpired) {
return <div />;
}
return component;
};

View File

@ -0,0 +1,38 @@
import React, { useCallback } from "react";
import { AppDispatch } from "app/store";
import { decodeToken } from "common/decodeToken";
import { useInterval } from "common/useInterval";
import { updateTokenAsync } from "features/auth/operations";
import { loadAccessToken } from "features/auth/utils";
import { DateTime } from "luxon";
import { useDispatch } from "react-redux";
//アクセストークンを更新する基準の秒数
const TOKEN_UPDATE_TIME = 5 * 60;
//アクセストークンの更新チェックを行う間隔(ミリ秒)
const TOKEN_UPDATE_INTERVAL_MS = 3 * 60 * 1000;
export const UpdateTokenTimer = () => {
const dispatch: AppDispatch = useDispatch();
// 期限が分以内であれば更新APIを呼ぶ
const updateToken = useCallback(async () => {
// localStorageからトークンを取得
const jwt = loadAccessToken();
// selectorに以下の判定処理を移したかったが、初期表示時の値でしか判定できないのでComponent内に置く
if (jwt) {
const token = decodeToken(jwt);
if (token) {
const { exp } = token;
const now = DateTime.local().toSeconds();
if (exp - now <= TOKEN_UPDATE_TIME) {
await dispatch(updateTokenAsync());
}
}
}
}, [dispatch]);
useInterval(updateToken, TOKEN_UPDATE_INTERVAL_MS);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
};

View File

@ -26,7 +26,6 @@ export const authSlice = createSlice({
) => {
const { accessToken, refreshToken } = action.payload;
if (accessToken && refreshToken) {
state.configuration.accessToken = accessToken;
state.accessToken = accessToken;
saveAccessToken(accessToken);
@ -35,7 +34,6 @@ export const authSlice = createSlice({
}
},
clearToken: (state) => {
state.configuration.accessToken = undefined;
state.accessToken = null;
state.refreshToken = null;
removeAccessToken();

View File

@ -0,0 +1,42 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "app/store";
import { AuthApi } from "../../api/api";
import { Configuration } from "../../api/configuration";
import { setToken } from "./authSlice";
export const updateTokenAsync = createAsyncThunk<
{
/* Empty Object */
},
void,
{
// rejectした時の返却値の型
rejectValue: {
/* Empty Object */
};
}
>("auth/updateTokenAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, refreshToken } = state.auth;
const config = new Configuration(configuration);
const authApi = new AuthApi(config);
try {
const { data } = await authApi.accessToken({
headers: { authorization: `Bearer ${refreshToken}` },
});
// アクセストークン・リフレッシュトークンをlocalStorageに保存
thunkApi.dispatch(
setToken({
accessToken: data.accessToken,
refreshToken: state.auth.refreshToken,
})
);
return {};
} catch (e) {
return thunkApi.rejectWithValue({});
}
});

View File

@ -0,0 +1,24 @@
import { RootState } from "app/store";
import { decodeToken } from "common/decodeToken";
import { DateTime } from "luxon";
/**
*
* @param state RootState
*/
export const isAuthenticatedSelector = (state: RootState): boolean =>
state.auth.accessToken !== null;
// トークンが有効期限内かどうかを判定する
export const isTokenExpired = (state: RootState): boolean => {
if (state.auth.accessToken) {
const token = decodeToken(state.auth.accessToken);
if (token) {
const { exp } = token;
const now = DateTime.local().toSeconds();
if (now <= exp) {
return false;
}
}
}
return true;
};

View File

@ -49,11 +49,5 @@ export const initialConfig = (): ConfigurationParameters => {
config.basePath = window.location.origin;
}
// localStorageからAccessTokenを復元する
const accessToken = loadAccessToken();
if (accessToken) {
config.accessToken = accessToken;
}
return config;
};

View File

@ -1,5 +1,3 @@
export * from "./loginSlice";
export * from "./state";
export * from "./selectors";
export * from "./operations";
export * from "./types";

View File

@ -1,5 +1,4 @@
import { createSlice } from "@reduxjs/toolkit";
import { loginAsync } from "./operations";
import { LoginState } from "./state";
const initialState: LoginState = {
@ -12,16 +11,8 @@ export const loginSlice = createSlice({
name: "login",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(loginAsync.pending, (state) => {
state.apps.loadState = "Loading";
});
builder.addCase(loginAsync.fulfilled, (state) => {
state.apps.loadState = "Loaded";
});
builder.addCase(loginAsync.rejected, (state) => {
state.apps.loadState = "LoadError";
});
extraReducers: () => {
//
},
});

View File

@ -43,7 +43,7 @@ export const loginAsync = createAsyncThunk<
})
);
return data;
return {};
} catch (e) {
return thunkApi.rejectWithValue({});
}

View File

@ -1,9 +1,6 @@
import { LoadState } from "./types";
export interface LoginState {
apps: Apps;
}
export interface Apps {
loadState: LoadState;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Apps {}

View File

@ -1,7 +1,9 @@
import { UpdateTokenTimer } from "components/auth/updateTokenTimer";
import React from "react";
export const AuthErrorPage = (): JSX.Element => (
<div>
<UpdateTokenTimer />
<p></p>
<br />
</div>

View File

@ -1,16 +1,16 @@
import { InteractionStatus, SilentRequest } from "@azure/msal-browser";
import { useMsal } from "@azure/msal-react";
import { useIsAuthenticated, useMsal } from "@azure/msal-react";
import { AppDispatch } from "app/store";
import { loginAsync } from "features/login";
import React, { useCallback, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
const LoginPage: React.FC = () => {
const LoginPage: React.FC = (): JSX.Element => {
const { accounts, instance, inProgress } = useMsal();
const dispatch: AppDispatch = useDispatch();
const navigate = useNavigate();
const isAuthenticated = useIsAuthenticated();
const login = useCallback(async () => {
const request: SilentRequest = {
scopes: ["openid"],
@ -32,10 +32,18 @@ const LoginPage: React.FC = () => {
useEffect(() => {
// B2CからリダイレクトされてB2Cへのログインが完了してからAPIを呼ぶ
if (inProgress === InteractionStatus.None) {
if (isAuthenticated && inProgress === InteractionStatus.None) {
login();
}
}, [accounts, dispatch, inProgress, instance, login]);
}, [
accounts,
dispatch,
inProgress,
instance,
isAuthenticated,
login,
navigate,
]);
return <h3>loading ...</h3>;
};

View File

@ -1,14 +1,23 @@
import { useMsal } from "@azure/msal-react";
import { AppDispatch } from "app/store";
import { UpdateTokenTimer } from "components/auth/updateTokenTimer";
import { clearToken } from "features/auth";
import React from "react";
import { useDispatch } from "react-redux";
const SamplePage: React.FC = () => {
const SamplePage: React.FC = (): JSX.Element => {
const { instance } = useMsal();
const dispatch: AppDispatch = useDispatch();
return (
<div>
<UpdateTokenTimer />
<h1>hello world!!</h1>
<button
type="button"
onClick={() => instance.logout({ postLogoutRedirectUri: "/" })}
onClick={() => {
instance.logout({ postLogoutRedirectUri: "/" });
dispatch(clearToken());
}}
>
sign out
</button>

View File

@ -1,8 +1,8 @@
import { useMsal } from "@azure/msal-react";
import { loginRequest } from "common/auth/msalConfig";
import { loginRequest } from "common/msalConfig";
import React from "react";
const TopPage: React.FC = () => {
const TopPage: React.FC = (): JSX.Element => {
const { instance } = useMsal();
return (