diff --git a/dictation_client/package-lock.json b/dictation_client/package-lock.json index 0363f53..2417d10 100644 --- a/dictation_client/package-lock.json +++ b/dictation_client/package-lock.json @@ -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" }, diff --git a/dictation_client/package.json b/dictation_client/package.json index 1387fb2..f9220fa 100644 --- a/dictation_client/package.json +++ b/dictation_client/package.json @@ -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", diff --git a/dictation_client/src/App.tsx b/dictation_client/src/App.tsx index f9a8653..eb1e4f2 100644 --- a/dictation_client/src/App.tsx +++ b/dictation_client/src/App.tsx @@ -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 ( <> diff --git a/dictation_client/src/AppRouter.tsx b/dictation_client/src/AppRouter.tsx index 107a3c8..7510a67 100644 --- a/dictation_client/src/AppRouter.tsx +++ b/dictation_client/src/AppRouter.tsx @@ -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 = () => ( } /> } /> - } /> } /> + } />} + /> ); diff --git a/dictation_client/src/common/decodeToken.ts b/dictation_client/src/common/decodeToken.ts new file mode 100644 index 0000000..1f80536 --- /dev/null +++ b/dictation_client/src/common/decodeToken.ts @@ -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; + } +}; diff --git a/dictation_client/src/common/auth/msalConfig.ts b/dictation_client/src/common/msalConfig.ts similarity index 100% rename from dictation_client/src/common/auth/msalConfig.ts rename to dictation_client/src/common/msalConfig.ts diff --git a/dictation_client/src/common/token.ts b/dictation_client/src/common/token.ts new file mode 100644 index 0000000..4d1e163 --- /dev/null +++ b/dictation_client/src/common/token.ts @@ -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; +}; diff --git a/dictation_client/src/common/useInterval.ts b/dictation_client/src/common/useInterval.ts new file mode 100644 index 0000000..ffe0115 --- /dev/null +++ b/dictation_client/src/common/useInterval.ts @@ -0,0 +1,22 @@ +import { useRef, useEffect } from "react"; + +export const useInterval = async ( + callback: () => Promise, + interval: number +) => { + const callbackRef = useRef<() => Promise>(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 + }, []); +}; diff --git a/dictation_client/src/components/auth/routeAuthGuard.tsx b/dictation_client/src/components/auth/routeAuthGuard.tsx new file mode 100644 index 0000000..78813d5 --- /dev/null +++ b/dictation_client/src/components/auth/routeAuthGuard.tsx @@ -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
; + } + + return component; +}; diff --git a/dictation_client/src/components/auth/updateTokenTimer.tsx b/dictation_client/src/components/auth/updateTokenTimer.tsx new file mode 100644 index 0000000..fff1e1f --- /dev/null +++ b/dictation_client/src/components/auth/updateTokenTimer.tsx @@ -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(); + + // 期限が5分以内であれば更新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 <>; +}; diff --git a/dictation_client/src/features/auth/authSlice.ts b/dictation_client/src/features/auth/authSlice.ts index b88c705..a35be42 100644 --- a/dictation_client/src/features/auth/authSlice.ts +++ b/dictation_client/src/features/auth/authSlice.ts @@ -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(); diff --git a/dictation_client/src/features/auth/operations.ts b/dictation_client/src/features/auth/operations.ts new file mode 100644 index 0000000..04c1288 --- /dev/null +++ b/dictation_client/src/features/auth/operations.ts @@ -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({}); + } +}); diff --git a/dictation_client/src/features/auth/selectors.ts b/dictation_client/src/features/auth/selectors.ts new file mode 100644 index 0000000..db5f6a9 --- /dev/null +++ b/dictation_client/src/features/auth/selectors.ts @@ -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; +}; diff --git a/dictation_client/src/features/auth/utils.ts b/dictation_client/src/features/auth/utils.ts index 8923183..8e0e16d 100644 --- a/dictation_client/src/features/auth/utils.ts +++ b/dictation_client/src/features/auth/utils.ts @@ -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; }; diff --git a/dictation_client/src/features/login/index.ts b/dictation_client/src/features/login/index.ts index fe55245..d3e1e79 100644 --- a/dictation_client/src/features/login/index.ts +++ b/dictation_client/src/features/login/index.ts @@ -1,5 +1,3 @@ export * from "./loginSlice"; export * from "./state"; -export * from "./selectors"; export * from "./operations"; -export * from "./types"; diff --git a/dictation_client/src/features/login/loginSlice.ts b/dictation_client/src/features/login/loginSlice.ts index 2fea776..f665f2f 100644 --- a/dictation_client/src/features/login/loginSlice.ts +++ b/dictation_client/src/features/login/loginSlice.ts @@ -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: () => { + // }, }); diff --git a/dictation_client/src/features/login/operations.ts b/dictation_client/src/features/login/operations.ts index a711f01..f5c2060 100644 --- a/dictation_client/src/features/login/operations.ts +++ b/dictation_client/src/features/login/operations.ts @@ -43,7 +43,7 @@ export const loginAsync = createAsyncThunk< }) ); - return data; + return {}; } catch (e) { return thunkApi.rejectWithValue({}); } diff --git a/dictation_client/src/features/login/state.ts b/dictation_client/src/features/login/state.ts index caed902..00862e6 100644 --- a/dictation_client/src/features/login/state.ts +++ b/dictation_client/src/features/login/state.ts @@ -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 {} diff --git a/dictation_client/src/pages/ErrorPage/index.tsx b/dictation_client/src/pages/ErrorPage/index.tsx index 4592ea0..e8fb978 100644 --- a/dictation_client/src/pages/ErrorPage/index.tsx +++ b/dictation_client/src/pages/ErrorPage/index.tsx @@ -1,7 +1,9 @@ +import { UpdateTokenTimer } from "components/auth/updateTokenTimer"; import React from "react"; export const AuthErrorPage = (): JSX.Element => (
+

ログインに失敗しました


diff --git a/dictation_client/src/pages/LoginPage/index.tsx b/dictation_client/src/pages/LoginPage/index.tsx index 4eb5ed4..32ac481 100644 --- a/dictation_client/src/pages/LoginPage/index.tsx +++ b/dictation_client/src/pages/LoginPage/index.tsx @@ -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

loading ...

; }; diff --git a/dictation_client/src/pages/SamplePage/index.tsx b/dictation_client/src/pages/SamplePage/index.tsx index f93f218..01407c5 100644 --- a/dictation_client/src/pages/SamplePage/index.tsx +++ b/dictation_client/src/pages/SamplePage/index.tsx @@ -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 (
+

hello world!!

diff --git a/dictation_client/src/pages/TopPage/index.tsx b/dictation_client/src/pages/TopPage/index.tsx index 40e0b80..a088937 100644 --- a/dictation_client/src/pages/TopPage/index.tsx +++ b/dictation_client/src/pages/TopPage/index.tsx @@ -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 (