株式会社 ONE WEDGEの@Shankouです。
前回に引き続き、認証ロジックの実装を行っていきたいと思います。
今回はアクセス制限の実装に手をつけていきます。
src/features/Auth/hooks/useRefreshToken.ts
最初にRefreshTokenとAccessTokenを扱うhooksを作成します。
まずはsrc/features/Auth/hooks/useRefreshToken.ts
を作成していきます。
今回のポイントとしてはuseSyncExternalStore
を使っていることでしょうか。
こちらは、React外部のデータソースのストアを監視し、その変更を検知することができるようになるというものです。
もっと分かりやすくいうとlocalStorage
とsessionStorage
の変更を検知するReact hooks
が作成できます。
タネは意外と簡単で更新時に登録したイベントを発火させることでReactがそれを拾ってくれる形ですね。
一点注意が必要なのは、useRefreshToken
はReact Component
の中からしか呼び出しができないことです。
通常のアクセスはgetRefreshToken
関数やsetRefreshToken
関数から行ってください。
import { useSyncExternalStore } from 'react'
const EVENT_NAME = 'updaterefreshtoken'
const subscribe = (callback: () => void) => {
window.addEventListener(EVENT_NAME, callback)
return () => {
window.removeEventListener(EVENT_NAME, callback)
}
}
export const REFRESH_TOKENE_KEY_NAME = 'refreshToken'
export const getRefreshToken = () => {
if (typeof window !== 'undefined') {
return window.localStorage.getItem(REFRESH_TOKENE_KEY_NAME) || ''
}
return ''
}
export const setRefreshToken = (token: string) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(REFRESH_TOKENE_KEY_NAME, token)
// RefreshTokenの更新イベントを発火させる
window.dispatchEvent(new Event(EVENT_NAME))
}
}
export const useRefreshToken = () => {
const refreshToken = useSyncExternalStore(
subscribe,
() => getRefreshToken(),
() => ''
)
return { refreshToken, setRefreshToken }
}
src/features/Auth/hooks/useAccessToken.ts
useRefreshToken
とほぼ同じになります。
import { useSyncExternalStore } from 'react'
const EVENT_NAME = 'updateaccesstoken'
const subscribe = (callback: () => void) => {
window.addEventListener(EVENT_NAME, callback)
return () => {
window.removeEventListener(EVENT_NAME, callback)
}
}
export const ACCESS_TOKEN_KEY_NAME = 'accessToken'
export const getAccessToken = () => {
if (typeof window !== 'undefined') {
return window.sessionStorage.getItem(ACCESS_TOKEN_KEY_NAME) || ''
}
return ''
}
export const setAccessToken = (token: string) => {
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(ACCESS_TOKEN_KEY_NAME, token)
// AccessTokenの更新イベントを発火させる
window.dispatchEvent(new Event(EVENT_NAME))
}
}
export const useAccessToken = () => {
const accessToken = useSyncExternalStore(
subscribe,
() => getAccessToken(),
() => ''
)
return { accessToken, setAccessToken }
}
src/app/()/login/page.tsx
というわけで、前回作成したsrc/app/()/login/page.tsx
を改修して、setAccessToken
とsetRefreshToken
を適用していきます
// sessionStorage.setItem('accessToken', result.accessToken) => setAccessToken(result.accessToken)
setAccessToken(result.accessToken)
// localStorage.setItem('refreshToken', result.refreshToken) => setRefreshToken(result.refreshToken)
setRefreshToken(result.refreshToken)
'use client'
import CustomInputField from '@/components/elements/CustomInputField'
import { setAccessToken } from '@/features/Auth/hooks/useAccessToken'
import { setRefreshToken } from '@/features/Auth/hooks/useRefreshToken'
import { PostLoginRequest, zPostLoginRequest } from '@/features/Auth/types/request/PostLoginRequest.type'
import { PostLoginResponse } from '@/features/Auth/types/response/PostLoginResponse.type'
import { useRouter } from 'next/navigation'
import { ReactElement } from 'react'
import { useForm, SubmitHandler } from 'react-hook-form'
export default function Login(): ReactElement {
const route = useRouter()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<PostLoginRequest>()
const onSubmit: SubmitHandler<PostLoginRequest> = async (data) => {
try {
const params: PostLoginRequest = zPostLoginRequest.parse(data)
/** 本来であればここでAPIを呼び出します **/
// const result: PostLoginResponse | false = await myAxios.post('/login', params)
// .then((res) => zPostLoginResponse.parse(res))
// .catch(() => false)
// if (result === false) {
// // APIエラー処理
// return
// }
/** 今回はフロント周りの動作のみのため、一旦適当なデータを用意 **/
const result: PostLoginResponse = { accessToken: 'hogehoge', refreshToken: 'fugafuga' }
setAccessToken(result.accessToken)
setRefreshToken(result.refreshToken)
route.push('/mypage')
} catch (e) {
console.error(e)
// エラー処理
}
}
return (
<>
<h1>ログイン</h1>
<div className='flex justify-center'>
<form className='m-2 w-96' onSubmit={handleSubmit(onSubmit)}>
<CustomInputField
type='email'
id='login-id'
label='メールアドレス'
className='mt-2'
error={errors.email?.message}
{...register('email', {
required: 'メールアドレスを入力してください',
})}
/>
<CustomInputField
type='password'
autoComplete='current-password'
minLength={8}
id='password'
label='パスワード'
className='mt-2'
error={errors.password?.message}
{...register('password', {
required: 'パスワードを入力してください',
minLength: {
value: 8,
message: 'パスワードは8文字以上で入力してください',
},
})}
/>
<button type='submit' className='mt-6 p-4 w-full border bg-slate-200/60'>ログイン</button>
</form>
</div>
</>
)
}
src/features/Auth/types/AuthenticationStatus.type.ts
それでは、AuthProvider
の前に認証ステータス状態を表す定義ファイルを作成します。
export const Waiting = Symbol('AUTHENTICATION_STATUS: WAITING')
export const Processing = Symbol('AUTHENTICATION_STATUS: PROCESSING')
export const Failed = Symbol('AUTHENTICATION_STATUS: FAILED')
export const Successful = Symbol('AUTHENTICATION_STATUS: SUCCESSFUL')
export type AuthenticationStatus = typeof Waiting | typeof Processing | typeof Failed | typeof Successful
src/features/Auth/providers/AuthProvider.tsx
こちらが認証状態を常時監視してステータスを通知してくれるAuthProvider.tsx
になります。
'use client'
import { ReactNode, createContext, useState, useEffect, useContext } from 'react'
import {
AuthenticationStatus,
Waiting,
Processing,
Failed,
Successful,
} from '@/features/Auth/types/AuthenticationStatus.type'
import { usePathname, useSearchParams } from 'next/navigation'
import { useAccessToken } from '@/features/Auth/hooks/useAccessToken'
import { useRefreshToken } from '@/features/Auth/hooks/useRefreshToken'
const AuthenticationStatusContext = createContext<AuthenticationStatus>(Waiting)
export function useAuthenticationStatusContext() {
return useContext(AuthenticationStatusContext)
}
type AuthProviderProps = {
children: ReactNode
}
export default function AuthProvider({ children }: AuthProviderProps) {
const pathname = usePathname()
const searchParams = useSearchParams()
const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>(Waiting)
const { accessToken } = useAccessToken()
const { refreshToken } = useRefreshToken()
let status = authenticationStatus
// 認証確認処理
useEffect(() => {
if (status !== Processing) {
setAuthenticationStatus((status = Waiting))
}
if (status === Waiting) {
setAuthenticationStatus((status = Processing))
; (async () => {
// accessTokenの有効期間を確認して、必要であればrefreshTokenを用いて更新を行う (後ほど実装)
// await refreshIfExpired()
if (refreshToken && accessToken) {
setAuthenticationStatus((status = Successful))
} else {
setAuthenticationStatus((status = Failed))
}
})()
}
}, [
// pathname & searchParamsを依存関係に含めることでページ遷移ごとに
// accessToken & refreshTokenを依存関係に含めることで各Tokenの更新ごとに認証確認処理を実行させる
pathname, searchParams, accessToken, refreshToken
])
return (
<AuthenticationStatusContext.Provider value={authenticationStatus}>
{children}
</AuthenticationStatusContext.Provider>
)
}
src/app/layout.tsx
作成したAuthProvider.tsx
を導入していきます。
layout.tsx
に組み込んで行きましょう。
注意事項としてはlayout.tsx
にReact hooks
は入れられないという点です。
使いたい場合はどうすればいいのか、解決方法は次項で説明します。
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Suspense } from 'react'
import AuthProvider from '@/features/Auth/providers/AuthProviders'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: '',
description: '',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang='ja'>
<body className={`min-h-screen bg-white ${inter.className}`}>
<Suspense>
<AuthProvider>
{children}
</AuthProvider>
</Suspense>
</body>
</html>
)
}
src/app/(authenticated)/template.tsx
前項の解決方法がこちら!
template.tsx
という特殊存在です。layout.tsx
とpage.tsx
の間へ自動的に組み込まれるので、明示的にインポートする必要はありません。
src/app/(authenticated)
に配置したtemplate.tsx
なので、認証済みのページにアクセスしようとした場合にのみ適用されます。
layout.tsx
レベルで組み込みたい処理はこちらに記述するといいでしょう。
今回はuseAuthenticationStatusContext
を用いて認証状態を確認し、未認証状態ならログインページに転送するという処理を行っています。
'use client'
import { useAuthenticationStatusContext } from '@/features/Auth/providers/AuthProviders'
import { Failed } from '@/features/Auth/types/AuthenticationStatus.type'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
type TemplateProps = {
children: React.ReactNode
}
export default function Template({ children }: TemplateProps) {
const router = useRouter()
const authenticationStatus = useAuthenticationStatusContext()
useEffect(() => {
switch (authenticationStatus) {
case Failed:
router.push('/login')
break
default:
break
}
}, [authenticationStatus])
return (
<>{children}</>
)
}
実はまだ続く
当初の目的であった認証状態でアクセス制限を行うロジックは概ね組めました。
ですが、もう少しだけ調整したい箇所が残っています。
APIの呼び出し時に自動でTokenを組み込んだりとか、refreshIfExpired関数とか、、、
それはまた次回ということで今回は締めたいと思います。
next > ⑤